cataract 0.2.4 → 0.3.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -3
  4. data/.rubocop.yml +1 -0
  5. data/BENCHMARKS.md +24 -6
  6. data/CHANGELOG.md +29 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +251 -458
  11. data/ext/cataract/cataract.h +127 -16
  12. data/ext/cataract/css_parser.c +1029 -700
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +13 -7
  18. data/lib/cataract/error.rb +49 -0
  19. data/lib/cataract/import_resolver.rb +36 -16
  20. data/lib/cataract/pure/byte_constants.rb +4 -1
  21. data/lib/cataract/pure/flatten.rb +143 -145
  22. data/lib/cataract/pure/parser.rb +623 -388
  23. data/lib/cataract/pure/serializer.rb +109 -104
  24. data/lib/cataract/pure/specificity.rb +112 -156
  25. data/lib/cataract/pure.rb +1 -6
  26. data/lib/cataract/stylesheet.rb +480 -410
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +1 -0
  29. metadata +3 -13
  30. data/ext/cataract/import_scanner.c +0 -174
  31. data/ext/cataract_old/cataract.c +0 -393
  32. data/ext/cataract_old/cataract.h +0 -250
  33. data/ext/cataract_old/css_parser.c +0 -933
  34. data/ext/cataract_old/extconf.rb +0 -67
  35. data/ext/cataract_old/import_scanner.c +0 -174
  36. data/ext/cataract_old/merge.c +0 -776
  37. data/ext/cataract_old/shorthand_expander.c +0 -902
  38. data/ext/cataract_old/specificity.c +0 -213
  39. data/ext/cataract_old/stylesheet.c +0 -290
  40. data/ext/cataract_old/value_splitter.c +0 -116
@@ -109,6 +109,9 @@ module Cataract
109
109
  # @option options [Hash] :parser ({}) Parser configuration options
110
110
  # - :selector_lists [Boolean] (true) Track selector lists for W3C-compliant serialization
111
111
  def initialize(options = {})
112
+ # Type validation
113
+ raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
114
+
112
115
  # Support :imports as alias for :import (backwards compatibility)
113
116
  options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
114
117
 
@@ -119,12 +122,27 @@ module Cataract
119
122
  base_dir: nil,
120
123
  absolute_paths: false,
121
124
  uri_resolver: nil,
122
- parser: {}
125
+ parser: {},
126
+ raise_parse_errors: false
123
127
  }.merge(options)
124
128
 
129
+ # Type validation for specific options
130
+ if @options[:import_fetcher] && !@options[:import_fetcher].respond_to?(:call)
131
+ raise TypeError, "import_fetcher must be a Proc or callable, got #{@options[:import_fetcher].class}"
132
+ end
133
+
134
+ if @options[:base_uri] && !@options[:base_uri].is_a?(String)
135
+ raise TypeError, "base_uri must be a String, got #{@options[:base_uri].class}"
136
+ end
137
+
138
+ if @options[:uri_resolver] && !@options[:uri_resolver].respond_to?(:call)
139
+ raise TypeError, "uri_resolver must be a Proc or callable, got #{@options[:uri_resolver].class}"
140
+ end
141
+
125
142
  # Parser options with defaults (stored for passing to parser)
126
143
  @parser_options = {
127
- selector_lists: true
144
+ selector_lists: true,
145
+ raise_parse_errors: @options[:raise_parse_errors]
128
146
  }.merge(@options[:parser] || {})
129
147
 
130
148
  @rules = [] # Flat array of Rule structs
@@ -151,16 +169,17 @@ module Cataract
151
169
  # @param source [Stylesheet] Source stylesheet being copied
152
170
  def initialize_copy(source)
153
171
  super
154
- @rules = source.instance_variable_get(:@rules).dup
155
- @media_queries = source.instance_variable_get(:@media_queries).dup
156
- @_next_media_query_id = source.instance_variable_get(:@_next_media_query_id)
157
- @media_index = source.instance_variable_get(:@media_index).transform_values(&:dup)
158
- @imports = source.instance_variable_get(:@imports).dup
159
- @_selector_lists = source.instance_variable_get(:@_selector_lists).transform_values(&:dup)
160
- @_next_selector_list_id = source.instance_variable_get(:@_next_selector_list_id)
161
- @_media_query_lists = source.instance_variable_get(:@_media_query_lists).transform_values(&:dup)
162
- @_next_media_query_list_id = source.instance_variable_get(:@_next_media_query_list_id)
163
- @parser_options = source.instance_variable_get(:@parser_options).dup
172
+ @options = source.options.dup
173
+ @rules = source.rules.dup
174
+ @media_queries = source.media_queries.dup
175
+ @_next_media_query_id = source.next_media_query_id
176
+ @media_index = source.media_index_cache.transform_values(&:dup)
177
+ @imports = source.imports.dup
178
+ @_selector_lists = source.selector_lists.transform_values(&:dup)
179
+ @_next_selector_list_id = source.next_selector_list_id
180
+ @_media_query_lists = source.media_query_lists.transform_values(&:dup)
181
+ @_next_media_query_list_id = source.next_media_query_list_id
182
+ @parser_options = source.parser_options.dup
164
183
  clear_memoized_caches
165
184
  @_hash = nil # Clear cached hash
166
185
  end
@@ -180,11 +199,12 @@ module Cataract
180
199
  #
181
200
  # @param filename [String] Path to the CSS file
182
201
  # @param base_dir [String] Base directory for resolving the filename (default: '.')
183
- # @param options [Hash] Options passed to Stylesheet.new
202
+ # @param options [Hash] Options passed to Stylesheet.new, and to load_file
203
+ # (e.g. dangerous_path_prefixes: [] to load a path load_file blocks by default)
184
204
  # @return [Stylesheet] A new Stylesheet containing the parsed CSS
185
205
  def self.load_file(filename, base_dir = '.', **options)
186
206
  sheet = new(options)
187
- sheet.load_file(filename, base_dir)
207
+ sheet.load_file(filename, base_dir, options)
188
208
  sheet
189
209
  end
190
210
 
@@ -427,49 +447,26 @@ module Cataract
427
447
  #
428
448
  # @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
429
449
  # - :all - Output all rules including base rules and all media queries
430
- # - :screen, :print, etc. - Output only rules from specified media query
431
- # - [:screen, :print] - Output rules from multiple media queries
450
+ # - :screen, :print, etc. - Output base rules plus rules from the specified media query
451
+ # - [:screen, :print] - Output base rules plus rules from multiple media queries
432
452
  #
433
453
  # Important: When filtering to specific media types, base rules (rules not
434
- # inside any @media block) are NOT included. Only rules explicitly inside
435
- # the requested @media queries are output. Use :all to include base rules.
454
+ # inside any @media block) are always included, since they apply regardless
455
+ # of media context. Only rules from OTHER @media queries are excluded.
436
456
  # @return [String] CSS string
437
457
  #
438
458
  # @example Get all CSS
439
459
  # sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
440
460
  # sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
441
461
  #
442
- # @example Filter to specific media type (excludes base rules)
443
- # sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
444
- # # Note: base rules like "body { color: black; }" are NOT included
462
+ # @example Filter to specific media type (base rules still included)
463
+ # sheet.to_s(media: :print) # => "body { color: black; } @media print { .footer { color: red; } }"
464
+ # # Note: base rules like "body { color: black; }" apply during print too, so they're kept
445
465
  #
446
466
  # @example Filter to multiple media types
447
467
  # sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
448
468
  def to_s(media: :all)
449
- which_media = media
450
- # Normalize to array for consistent filtering
451
- which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
452
-
453
- # If :all is present, return everything (no filtering)
454
- if which_media_array.include?(:all)
455
- Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
456
- else
457
- # Collect all rule IDs that match the requested media types
458
- matching_rule_ids = []
459
- mi = media_index # Build media_index if needed
460
- which_media_array.each do |media_sym|
461
- if mi[media_sym]
462
- matching_rule_ids.concat(mi[media_sym])
463
- end
464
- end
465
- matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
466
-
467
- # Build filtered rules array (keep original IDs, no recreation needed)
468
- filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
469
-
470
- # Serialize with filtered data
471
- Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
472
- end
469
+ Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
473
470
  end
474
471
  alias to_css to_s
475
472
 
@@ -479,10 +476,10 @@ module Cataract
479
476
  # Rules are formatted with each declaration on its own line, and media queries
480
477
  # are properly indented. Optionally filters output to specific media queries.
481
478
  #
482
- # @param which_media [Symbol, Array<Symbol>] Optional media filter (default: :all)
479
+ # @param media [Symbol, Array<Symbol>] Optional media filter (default: :all)
483
480
  # - :all - Output all rules including base rules and all media queries
484
- # - :screen, :print, etc. - Output only rules from specified media query
485
- # - [:screen, :print] - Output rules from multiple media queries
481
+ # - :screen, :print, etc. - Output base rules plus rules from the specified media query
482
+ # - [:screen, :print] - Output base rules plus rules from multiple media queries
486
483
  #
487
484
  # @return [String] Formatted CSS string
488
485
  #
@@ -490,43 +487,12 @@ module Cataract
490
487
  # sheet.to_formatted_s
491
488
  # # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
492
489
  #
493
- # @example Filter to specific media type
494
- # sheet.to_formatted_s(:print)
490
+ # @example Filter to specific media type (base rules still included)
491
+ # sheet.to_formatted_s(media: :print)
495
492
  #
496
493
  # @see #to_s For compact single-line output
497
494
  def to_formatted_s(media: :all)
498
- which_media = media
499
- # Normalize to array for consistent filtering
500
- which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
501
-
502
- # If :all is present, return everything (no filtering)
503
- if which_media_array.include?(:all)
504
- Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
505
- else
506
- # Collect all rule IDs that match the requested media types
507
- matching_rule_ids = []
508
- mi = media_index # Build media_index if needed
509
-
510
- # Include rules not in any media query (they apply to all media)
511
- media_rule_ids = mi.values.flatten.uniq
512
- all_rule_ids = (0...@rules.length).to_a
513
- non_media_rule_ids = all_rule_ids - media_rule_ids
514
- matching_rule_ids.concat(non_media_rule_ids)
515
-
516
- # Include rules from requested media types
517
- which_media_array.each do |media_sym|
518
- if mi[media_sym]
519
- matching_rule_ids.concat(mi[media_sym])
520
- end
521
- end
522
- matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
523
-
524
- # Build filtered rules array (keep original IDs, no recreation needed)
525
- filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
526
-
527
- # Serialize with filtered data
528
- Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
529
- end
495
+ Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
530
496
  end
531
497
 
532
498
  # Get number of rules
@@ -560,14 +526,15 @@ module Cataract
560
526
  #
561
527
  # @param filename [String] Path to the CSS file
562
528
  # @param base_dir [String] Base directory for resolving the filename (default: '.')
529
+ # @param options [Hash] Passed through to load_uri (e.g. dangerous_path_prefixes: [])
563
530
  # @return [self] Returns self for method chaining
564
- def load_file(filename, base_dir = '.', _media_types = :all)
531
+ def load_file(filename, base_dir = '.', options = {})
565
532
  # Normalize file path and convert to file:// URI
566
533
  file_path = File.expand_path(filename, base_dir)
567
534
  file_uri = "file://#{file_path}"
568
535
 
569
536
  # Delegate to load_uri which handles imports and base_path
570
- load_uri(file_uri)
537
+ load_uri(file_uri, options)
571
538
  end
572
539
 
573
540
  # Load CSS from a URI and add to this stylesheet.
@@ -577,20 +544,25 @@ module Cataract
577
544
  # @return [self] Returns self for method chaining
578
545
  def load_uri(uri, options = {})
579
546
  require 'uri'
580
- require 'net/http'
581
547
 
582
548
  uri_obj = URI(uri)
549
+ # Reuse the same validation and fetch collaborator @import resolution
550
+ # uses (ImportResolver, via DefaultFetcher/open-uri) instead of a
551
+ # second, separate Net::HTTP implementation with no validation at all -
552
+ # that duplicate implementation also didn't follow redirects, unlike
553
+ # this one. LOAD_DEFAULTS (rather than SAFE_DEFAULTS) reflects that the
554
+ # caller chose this exact URI themselves, so http/any-extension are
555
+ # allowed by default; dangerous_path_prefixes still applies unless the
556
+ # caller overrides it (e.g. dangerous_path_prefixes: []).
557
+ opts = ImportResolver.normalize_options(options, defaults: ImportResolver::LOAD_DEFAULTS)
558
+ fetcher = opts[:fetcher] || @options[:import_fetcher] || ImportResolver::DefaultFetcher.new
583
559
  css_content = nil
584
560
  file_path = nil
585
561
 
586
562
  case uri_obj.scheme
587
563
  when 'http', 'https'
588
- response = Net::HTTP.get_response(uri_obj)
589
- unless response.is_a?(Net::HTTPSuccess)
590
- raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
591
- end
592
-
593
- css_content = response.body
564
+ ImportResolver.validate_url(uri, opts)
565
+ css_content = fetcher.call(uri, opts)
594
566
  when 'file', nil
595
567
  # file:// URI or relative path
596
568
  path = uri_obj.scheme == 'file' ? uri_obj.path : uri
@@ -607,7 +579,9 @@ module Cataract
607
579
  @options[:import] = @options[:import].merge(base_path: file_dir)
608
580
  end
609
581
 
610
- css_content = File.read(file_path)
582
+ file_uri = "file://#{file_path}"
583
+ ImportResolver.validate_url(file_uri, opts)
584
+ css_content = fetcher.call(file_uri, opts)
611
585
  else
612
586
  raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
613
587
  end
@@ -708,9 +682,7 @@ module Cataract
708
682
  # Clean up empty media_index entries
709
683
  @media_index.delete_if { |_media, ids| ids.empty? }
710
684
 
711
- # Clean up unused MediaQuery objects (those not referenced by any rule)
712
- used_mq_ids = @rules.filter_map { |r| r.media_query_id if r.is_a?(Rule) }.to_set
713
- @media_queries.select! { |mq| used_mq_ids.include?(mq.id) }
685
+ compact_media_queries!
714
686
 
715
687
  # Update rule IDs in remaining rules
716
688
  @rules.each_with_index { |rule, new_id| rule.id = new_id }
@@ -743,115 +715,12 @@ module Cataract
743
715
  effective_base_dir = base_dir || @options[:base_dir]
744
716
  effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
745
717
 
746
- # Get current rule ID offset
747
- offset = @_last_rule_id || 0
748
-
749
- # Build parser options with URL conversion settings
750
- parse_options = @parser_options.dup
751
- if effective_absolute_paths && effective_base_uri
752
- parse_options[:base_uri] = effective_base_uri
753
- parse_options[:absolute_paths] = true
754
- parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
755
- end
718
+ parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
756
719
 
757
720
  # Parse CSS first (this extracts @import statements into result[:imports])
758
721
  result = Cataract._parse_css(css, parse_options)
759
722
 
760
- # Merge selector_lists with offsetted IDs
761
- list_id_offset = @_next_selector_list_id
762
- if result[:_selector_lists] && !result[:_selector_lists].empty?
763
- result[:_selector_lists].each do |list_id, rule_ids|
764
- new_list_id = list_id + list_id_offset
765
- offsetted_rule_ids = rule_ids.map { |id| id + offset }
766
- @_selector_lists[new_list_id] = offsetted_rule_ids
767
- end
768
- @_next_selector_list_id = list_id_offset + result[:_selector_lists].size
769
- end
770
-
771
- # Merge media_query_lists with offsetted IDs
772
- media_query_id_offset = @_next_media_query_id
773
- mq_list_id_offset = @_next_media_query_list_id
774
- if result[:_media_query_lists] && !result[:_media_query_lists].empty?
775
- result[:_media_query_lists].each do |list_id, mq_ids|
776
- new_list_id = list_id + mq_list_id_offset
777
- offsetted_mq_ids = mq_ids.map { |id| id + media_query_id_offset }
778
- @_media_query_lists[new_list_id] = offsetted_mq_ids
779
- end
780
- @_next_media_query_list_id = mq_list_id_offset + result[:_media_query_lists].size
781
- end
782
-
783
- # Merge rules with offsetted IDs
784
- new_rules = result[:rules]
785
- new_rules.each do |rule|
786
- rule.id += offset
787
- # Update selector_list_id to point to offsetted list (only for Rule, not AtRule)
788
- if rule.is_a?(Rule) && rule.selector_list_id
789
- rule.selector_list_id += list_id_offset
790
- end
791
- # Update media_query_id to point to offsetted MediaQuery
792
- if rule.is_a?(Rule) && rule.media_query_id
793
- rule.media_query_id += media_query_id_offset
794
- end
795
- @rules << rule
796
- end
797
-
798
- # Merge media_index with offsetted IDs
799
- result[:_media_index].each do |media_sym, rule_ids|
800
- offsetted_ids = rule_ids.map { |id| id + offset }
801
- if @media_index[media_sym]
802
- @media_index[media_sym].concat(offsetted_ids)
803
- else
804
- @media_index[media_sym] = offsetted_ids
805
- end
806
- end
807
-
808
- # Merge media_queries with offsetted IDs
809
- if result[:media_queries]
810
- result[:media_queries].each do |mq|
811
- mq.id += media_query_id_offset
812
- @media_queries << mq
813
- end
814
- @_next_media_query_id += result[:media_queries].length
815
- end
816
-
817
- # Update last rule ID
818
- @_last_rule_id = offset + new_rules.length
819
-
820
- # Merge imports with offsetted IDs
821
- if result[:imports]
822
- new_imports = result[:imports]
823
- new_imports.each do |import|
824
- import.id += offset
825
- @imports << import
826
- end
827
-
828
- # Resolve imports if configured
829
- if @options[:import]
830
- # Extract imported_urls and depth from options
831
- if @options[:import].is_a?(Hash)
832
- imported_urls = @options[:import][:imported_urls] || []
833
- depth = @options[:import][:depth] || 0
834
- else
835
- imported_urls = []
836
- depth = 0
837
- end
838
-
839
- # Build import options with base_uri/base_dir for URL resolution
840
- import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
841
- import_opts[:base_uri] = effective_base_uri if effective_base_uri
842
- import_opts[:base_path] = effective_base_dir if effective_base_dir
843
-
844
- resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
845
- end
846
- end
847
-
848
- # Set charset if not already set
849
- @charset ||= result[:charset]
850
-
851
- # Track if we have any nesting (for serialization optimization)
852
- @_has_nesting = result[:_has_nesting]
853
-
854
- clear_memoized_caches
723
+ merge_parsed_block!(result, effective_base_uri, effective_base_dir)
855
724
 
856
725
  self
857
726
  end
@@ -902,7 +771,7 @@ module Cataract
902
771
  def ==(other)
903
772
  return false unless other.is_a?(Stylesheet)
904
773
  return false unless rules == other.rules
905
- return false unless @media_queries == other.instance_variable_get(:@media_queries)
774
+ return false unless @media_queries == other.media_queries
906
775
 
907
776
  true
908
777
  end
@@ -945,9 +814,9 @@ module Cataract
945
814
  # @return [self] Returns self for method chaining
946
815
  def flatten!
947
816
  flattened = Cataract.flatten(self)
948
- @rules = flattened.instance_variable_get(:@rules)
949
- @media_index = flattened.instance_variable_get(:@media_index)
950
- @_has_nesting = flattened.instance_variable_get(:@_has_nesting)
817
+ @rules = flattened.rules
818
+ @media_index = flattened.media_index_cache
819
+ @_has_nesting = flattened.has_nesting
951
820
  self
952
821
  end
953
822
  alias cascade! flatten!
@@ -968,29 +837,23 @@ module Cataract
968
837
  def concat(other)
969
838
  raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
970
839
 
971
- # Get the current offset for rule IDs
840
+ # Get the current offset for rule/media-query/selector-list IDs
972
841
  offset = @rules.length
842
+ list_id_offset = merge_selector_lists!(other.selector_lists, rule_id_offset: offset)
843
+ mq_id_offset = merge_media_queries!(other.media_queries)
844
+ merge_media_query_lists!(other.media_query_lists, mq_id_offset: mq_id_offset)
973
845
 
974
- # Add rules with updated IDs
846
+ # Add rules with updated IDs and cross-references
975
847
  other.rules.each do |rule|
976
848
  new_rule = rule.dup
977
- new_rule.id = @rules.length
849
+ rebase_rule!(new_rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
978
850
  @rules << new_rule
979
851
  end
980
852
 
981
- # Merge media_index with offsetted IDs
982
- other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
983
- offsetted_ids = rule_ids.map { |id| id + offset }
984
- if @media_index[media_sym]
985
- @media_index[media_sym].concat(offsetted_ids)
986
- else
987
- @media_index[media_sym] = offsetted_ids
988
- end
989
- end
853
+ merge_media_index!(other.media_index_cache, rule_id_offset: offset)
990
854
 
991
855
  # Update nesting flag if other has nesting
992
- other_has_nesting = other.instance_variable_get(:@_has_nesting)
993
- @_has_nesting = true if other_has_nesting
856
+ @_has_nesting = true if other.has_nesting
994
857
 
995
858
  clear_memoized_caches
996
859
 
@@ -1035,7 +898,7 @@ module Cataract
1035
898
  result.rules.delete_at(idx)
1036
899
 
1037
900
  # Update media_index: remove this rule ID and decrement higher IDs
1038
- result.instance_variable_get(:@media_index).each_value do |ids|
901
+ result.media_index_cache.each_value do |ids|
1039
902
  ids.delete(idx)
1040
903
  ids.map! { |id| id > idx ? id - 1 : id }
1041
904
  end
@@ -1045,58 +908,301 @@ module Cataract
1045
908
  result.rules.each_with_index { |rule, new_id| rule.id = new_id }
1046
909
 
1047
910
  # Clean up empty media_index entries
1048
- result.instance_variable_get(:@media_index).delete_if { |_media, ids| ids.empty? }
911
+ result.media_index_cache.delete_if { |_media, ids| ids.empty? }
912
+
913
+ result.send(:compact_media_queries!)
914
+
915
+ # Clear memoized cache
916
+ result.send(:clear_memoized_caches)
917
+ result._hash = nil
918
+
919
+ result
920
+ end
921
+
922
+ protected
923
+
924
+ # Internal accessors for another Stylesheet instance's private state.
925
+ # Protected (not public) so this stays reachable from sibling instances
926
+ # within this class (dup/clone, concat, resolve_imports, -) without
927
+ # becoming part of the public API - and not plain attr_readers, since
928
+ # several ivar names would collide with unrelated public methods
929
+ # (notably #media_index, which lazily rebuilds rather than exposing the
930
+ # raw cache these need).
931
+
932
+ attr_reader :options, :parser_options
933
+
934
+ def next_media_query_id
935
+ @_next_media_query_id
936
+ end
937
+
938
+ def next_selector_list_id
939
+ @_next_selector_list_id
940
+ end
941
+
942
+ def next_media_query_list_id
943
+ @_next_media_query_list_id
944
+ end
945
+
946
+ # @return [Hash{Symbol => Array<Integer>}] the raw cached media index,
947
+ # without triggering the public #media_index reader's lazy rebuild
948
+ def media_index_cache
949
+ @media_index
950
+ end
951
+
952
+ def has_nesting
953
+ @_has_nesting
954
+ end
955
+
956
+ def selector_lists
957
+ @_selector_lists
958
+ end
959
+
960
+ def media_query_lists
961
+ @_media_query_lists
962
+ end
963
+
964
+ attr_writer :_hash
965
+
966
+ private
967
+
968
+ # Offset and append MediaQuery objects into @media_queries, without
969
+ # mutating the source objects (a caller may still hold its own
970
+ # references to them, e.g. concat's `other`). Returns the id offset
971
+ # applied, so callers can rebase any rule/import media_query_id that
972
+ # pointed into the source collection.
973
+ #
974
+ # @param source_media_queries [Array<MediaQuery>, nil]
975
+ # @return [Integer] id offset applied to each merged MediaQuery
976
+ def merge_media_queries!(source_media_queries)
977
+ mq_id_offset = @_next_media_query_id
978
+ return mq_id_offset if source_media_queries.nil? || source_media_queries.empty?
979
+
980
+ source_media_queries.each do |mq|
981
+ new_mq = mq.dup
982
+ new_mq.id += mq_id_offset
983
+ @media_queries << new_mq
984
+ end
985
+ @_next_media_query_id += source_media_queries.size
986
+
987
+ mq_id_offset
988
+ end
989
+
990
+ # Offset and merge a selector_lists hash (list_id => [rule_ids]) into
991
+ # @_selector_lists. List ids are always rebased onto the next available
992
+ # id; rule ids are rebased by rule_id_offset (0 when the caller defers
993
+ # rule-id fixups to a later bulk renumber).
994
+ #
995
+ # @param source_lists [Hash{Integer => Array<Integer>}, nil]
996
+ # @param rule_id_offset [Integer]
997
+ # @return [Integer] id offset applied to the source's list ids
998
+ def merge_selector_lists!(source_lists, rule_id_offset: 0)
999
+ list_id_offset = @_next_selector_list_id
1000
+ return list_id_offset if source_lists.nil? || source_lists.empty?
1001
+
1002
+ source_lists.each do |list_id, rule_ids|
1003
+ @_selector_lists[list_id + list_id_offset] = rule_ids.map { |id| id + rule_id_offset }
1004
+ end
1005
+ @_next_selector_list_id = list_id_offset + source_lists.size
1006
+
1007
+ list_id_offset
1008
+ end
1009
+
1010
+ # Same shape as merge_selector_lists!, for @_media_query_lists
1011
+ # (list_id => [media_query_ids]).
1012
+ #
1013
+ # @param source_lists [Hash{Integer => Array<Integer>}, nil]
1014
+ # @param mq_id_offset [Integer]
1015
+ # @return [Integer] id offset applied to the source's list ids
1016
+ def merge_media_query_lists!(source_lists, mq_id_offset: 0)
1017
+ list_id_offset = @_next_media_query_list_id
1018
+ return list_id_offset if source_lists.nil? || source_lists.empty?
1019
+
1020
+ source_lists.each do |list_id, mq_ids|
1021
+ @_media_query_lists[list_id + list_id_offset] = mq_ids.map { |id| id + mq_id_offset }
1022
+ end
1023
+ @_next_media_query_list_id = list_id_offset + source_lists.size
1024
+
1025
+ list_id_offset
1026
+ end
1027
+
1028
+ # Merge a media_index hash (media_sym => [rule_ids]) into @media_index,
1029
+ # offsetting the rule ids.
1030
+ #
1031
+ # @param source_index [Hash{Symbol => Array<Integer>}, nil]
1032
+ # @param rule_id_offset [Integer]
1033
+ # @return [void]
1034
+ def merge_media_index!(source_index, rule_id_offset:)
1035
+ return if source_index.nil? || source_index.empty?
1036
+
1037
+ source_index.each do |media_sym, rule_ids|
1038
+ offsetted_ids = rule_ids.map { |id| id + rule_id_offset }
1039
+ if @media_index[media_sym]
1040
+ @media_index[media_sym].concat(offsetted_ids)
1041
+ else
1042
+ @media_index[media_sym] = offsetted_ids
1043
+ end
1044
+ end
1045
+ end
1046
+
1047
+ # Rebase a rule/at-rule's own id and its cross-references (Rule's
1048
+ # selector_list_id, and media_query_id on both Rule and AtRule) by the
1049
+ # given offsets, mutating it in place. Callers merging from a live
1050
+ # Stylesheet they don't own (e.g. concat) must dup the rule first.
1051
+ #
1052
+ # @param rule [Rule, AtRule]
1053
+ # @param rule_id_offset [Integer]
1054
+ # @param list_id_offset [Integer]
1055
+ # @param mq_id_offset [Integer]
1056
+ # @return [void]
1057
+ def rebase_rule!(rule, rule_id_offset:, list_id_offset: 0, mq_id_offset: 0)
1058
+ rule.id += rule_id_offset
1059
+ rule.media_query_id += mq_id_offset if rule.media_query_id
1060
+ return unless rule.is_a?(Rule)
1061
+
1062
+ rule.selector_list_id += list_id_offset if rule.selector_list_id
1063
+ end
1064
+
1065
+ # Build parser options for a block, enabling relative -> absolute URL
1066
+ # conversion when both absolute_paths and a base_uri are in effect.
1067
+ #
1068
+ # @return [Hash] Parser options
1069
+ def build_parse_options(effective_base_uri, effective_absolute_paths)
1070
+ parse_options = @parser_options.dup
1071
+ if effective_absolute_paths && effective_base_uri
1072
+ parse_options[:base_uri] = effective_base_uri
1073
+ parse_options[:absolute_paths] = true
1074
+ parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
1075
+ end
1076
+ parse_options
1077
+ end
1078
+
1079
+ # Merge a freshly parsed CSS block's result into this stylesheet's
1080
+ # rules, media queries, selector/media-query lists, and index, then
1081
+ # resolve any @import statements it contained.
1082
+ #
1083
+ # @param result [Hash] Return value of Cataract._parse_css
1084
+ # @return [void]
1085
+ def merge_parsed_block!(result, effective_base_uri, effective_base_dir)
1086
+ offset = @_last_rule_id || 0
1087
+
1088
+ list_id_offset = merge_selector_lists!(result[:_selector_lists], rule_id_offset: offset)
1089
+ mq_id_offset = merge_media_queries!(result[:media_queries])
1090
+ merge_media_query_lists!(result[:_media_query_lists], mq_id_offset: mq_id_offset)
1091
+
1092
+ new_rules = result[:rules]
1093
+ new_rules.each do |rule|
1094
+ rebase_rule!(rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
1095
+ @rules << rule
1096
+ end
1097
+ @_last_rule_id = offset + new_rules.length
1098
+
1099
+ merge_media_index!(result[:_media_index], rule_id_offset: offset)
1100
+ merge_block_imports!(result[:imports], offset, mq_id_offset, effective_base_uri, effective_base_dir)
1101
+
1102
+ @charset ||= result[:charset]
1103
+ @_has_nesting = result[:_has_nesting]
1104
+
1105
+ clear_memoized_caches
1106
+ end
1107
+
1108
+ # Merge a freshly parsed block's @import statements into @imports (with
1109
+ # offsetted IDs), then kick off import resolution if enabled.
1110
+ #
1111
+ # @return [void]
1112
+ def merge_block_imports!(new_imports, offset, mq_id_offset, effective_base_uri, effective_base_dir)
1113
+ return unless new_imports
1049
1114
 
1050
- # Clean up unused MediaQuery objects and rebuild ID mapping
1051
- used_mq_ids = Set.new
1052
- result.rules.each do |rule|
1053
- used_mq_ids << rule.media_query_id if rule.respond_to?(:media_query_id) && rule.media_query_id
1115
+ new_imports.each do |import|
1116
+ import.id += offset
1117
+ import.media_query_id += mq_id_offset if import.media_query_id
1118
+ @imports << import
1119
+ end
1120
+
1121
+ return unless @options[:import]
1122
+
1123
+ if @options[:import].is_a?(Hash)
1124
+ imported_urls = @options[:import][:imported_urls] || []
1125
+ depth = @options[:import][:depth] || 0
1126
+ else
1127
+ imported_urls = []
1128
+ depth = 0
1054
1129
  end
1055
1130
 
1056
- # Build old_id => new_id mapping
1057
- # Keep MediaQuery objects that are used, maintaining their IDs
1131
+ import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
1132
+ import_opts[:base_uri] = effective_base_uri if effective_base_uri
1133
+ import_opts[:base_path] = effective_base_dir if effective_base_dir
1134
+
1135
+ resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
1136
+ end
1137
+
1138
+ # Remove MediaQuery objects no longer referenced by any rule, renumbering
1139
+ # the ones that remain so mq.id keeps matching its position in
1140
+ # @media_queries. Lookups elsewhere (parser, serializer) treat
1141
+ # @media_queries[id] as positional, so simply compacting the array
1142
+ # without renumbering would desync mq.id from rule.media_query_id and
1143
+ # @_media_query_lists as soon as an earlier entry gets removed.
1144
+ #
1145
+ # Shared by #remove_rules! (via self) and #- (via result.send, since it
1146
+ # operates on a separate Stylesheet instance).
1147
+ #
1148
+ # @return [void]
1149
+ def compact_media_queries!
1150
+ # Rule and AtRule both define media_query_id directly (it's a member of
1151
+ # both structs), so every element of @rules responds to it - no need
1152
+ # for a respond_to?/is_a? guard here. filter_map keeps only rules that
1153
+ # actually have one set (i.e. are scoped to a media query).
1154
+ used_mq_ids = @rules.filter_map(&:media_query_id).to_set
1155
+
1058
1156
  old_to_new_mq_id = {}
1059
1157
  kept_mqs = []
1060
- result.instance_variable_get(:@media_queries).each do |mq|
1158
+ @media_queries.each do |mq|
1061
1159
  next unless used_mq_ids.include?(mq.id)
1062
1160
 
1063
1161
  old_to_new_mq_id[mq.id] = kept_mqs.size
1064
1162
  mq.id = kept_mqs.size
1065
1163
  kept_mqs << mq
1066
1164
  end
1165
+ @media_queries = kept_mqs
1067
1166
 
1068
- # Replace media_queries array with kept ones
1069
- result.instance_variable_set(:@media_queries, kept_mqs)
1070
-
1071
- # Update media_query_id references in rules
1072
- result.rules.each do |rule|
1073
- if rule.respond_to?(:media_query_id) && rule.media_query_id
1074
- rule.media_query_id = old_to_new_mq_id[rule.media_query_id]
1075
- end
1167
+ @rules.each do |rule|
1168
+ rule.media_query_id = old_to_new_mq_id[rule.media_query_id] if rule.media_query_id
1076
1169
  end
1077
1170
 
1078
- # Update media_query_lists with new IDs
1079
- result.instance_variable_get(:@_media_query_lists).each_value do |mq_ids|
1080
- mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact!
1081
- end
1171
+ @_media_query_lists.each_value { |mq_ids| mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact! }
1172
+ @_media_query_lists.delete_if { |_list_id, mq_ids| mq_ids.empty? }
1173
+ end
1082
1174
 
1083
- # Clean up media_query_lists that are now empty
1084
- result.instance_variable_get(:@_media_query_lists).delete_if { |_list_id, mq_ids| mq_ids.empty? }
1175
+ # Filter rules down to those relevant for the given media type(s).
1176
+ #
1177
+ # Shared by #to_s and #to_formatted_s so both serialize the same rule set
1178
+ # for a given filter. Base rules (not inside any @media block) always
1179
+ # apply regardless of media context, so they're included alongside any
1180
+ # explicitly requested media types.
1181
+ #
1182
+ # @param media [Symbol, Array<Symbol>] Media type(s) to filter to
1183
+ # @return [Array<Rule>] Filtered rules, in original order
1184
+ def filter_rules_by_media(media)
1185
+ which_media_array = media.is_a?(Array) ? media : [media]
1186
+ return @rules if which_media_array.include?(:all)
1085
1187
 
1086
- # Clear memoized cache
1087
- result.instance_variable_set(:@selectors, nil)
1088
- result.instance_variable_set(:@_hash, nil)
1188
+ mi = media_index # Build media_index if needed
1089
1189
 
1090
- result
1091
- end
1190
+ # Base rules (not in any media query) apply to all media contexts.
1191
+ # Built as a Set so the membership check below is O(1) per rule instead
1192
+ # of the O(n) scan an Array#- would do, and to avoid materializing a
1193
+ # separate 0..N array of every rule id just to subtract from it.
1194
+ media_rule_id_set = Set.new
1195
+ mi.each_value { |ids| media_rule_id_set.merge(ids) }
1196
+ matching_rule_ids = (0...@rules.length).reject { |rule_id| media_rule_id_set.include?(rule_id) }
1092
1197
 
1093
- private
1198
+ # Include rules from requested media types
1199
+ which_media_array.each do |media_sym|
1200
+ matching_rule_ids.concat(mi[media_sym]) if mi[media_sym]
1201
+ end
1202
+ matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
1094
1203
 
1095
- # @private
1096
- # Internal index mapping media query symbols to rule IDs for efficient filtering.
1097
- # This is an implementation detail and should not be relied upon by external code.
1098
- # @return [Hash<Symbol, Array<Integer>>]
1099
- attr_reader :_media_index
1204
+ matching_rule_ids.sort!.map! { |rule_id| @rules[rule_id] }
1205
+ end
1100
1206
 
1101
1207
  # Resolve @import statements by fetching and merging imported stylesheets
1102
1208
  #
@@ -1120,146 +1226,150 @@ module Cataract
1120
1226
  imports.each do |import|
1121
1227
  next if import.resolved # Skip already resolved imports
1122
1228
 
1123
- url = import.url
1124
- import_media_query_id = import.media_query_id
1229
+ resolve_single_import!(import, opts, fetcher, imported_urls, depth)
1230
+ end
1125
1231
 
1126
- # Validate URL
1127
- ImportResolver.validate_url(url, opts)
1232
+ # Renumber all rule IDs to be sequential in document order.
1233
+ # This is O(n) and very fast (~1ms for 30k rules). Only needed if we
1234
+ # actually resolved imports.
1235
+ renumber_after_import_resolution! if imports.length > 0
1236
+ end
1128
1237
 
1129
- # Check for circular references
1130
- raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
1238
+ # Fetch, parse, and merge one @import statement's target stylesheet into
1239
+ # this one, inserted at the import's original document position.
1240
+ #
1241
+ # @return [void]
1242
+ def resolve_single_import!(import, opts, fetcher, imported_urls, depth)
1243
+ url = import.url
1244
+ import_media_query_id = import.media_query_id
1131
1245
 
1132
- # Fetch imported CSS
1133
- imported_css = fetcher.call(url, opts)
1246
+ # Validate URL
1247
+ ImportResolver.validate_url(url, opts)
1134
1248
 
1135
- # Parse imported CSS recursively
1136
- imported_urls_copy = imported_urls.dup
1137
- imported_urls_copy << url
1249
+ # Check for circular references
1250
+ raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
1138
1251
 
1139
- # Determine the base URI for the imported file
1140
- # This becomes the new base for resolving relative URLs in the imported CSS
1141
- imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
1252
+ # Fetch imported CSS
1253
+ imported_css = fetcher.call(url, opts)
1142
1254
 
1143
- # Build parse options for imported CSS
1144
- parse_opts = {
1145
- import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1146
- parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
1147
- }
1255
+ # Parse imported CSS recursively
1256
+ imported_urls_copy = imported_urls.dup
1257
+ imported_urls_copy << url
1148
1258
 
1149
- # If URL conversion is enabled (base_uri present), enable it for imported files too
1150
- if opts[:base_uri]
1151
- parse_opts[:absolute_paths] = true
1152
- parse_opts[:base_uri] = imported_base_uri
1153
- parse_opts[:uri_resolver] = opts[:uri_resolver]
1154
- end
1259
+ # Determine the base URI for the imported file
1260
+ # This becomes the new base for resolving relative URLs in the imported CSS
1261
+ imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
1155
1262
 
1156
- # Pass parent import's media query context to parser so nested imports can combine
1157
- if import_media_query_id
1158
- parent_mq = @media_queries[import_media_query_id]
1159
- parse_opts[:parser][:parent_import_media_type] = parent_mq.type
1160
- parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
1161
- end
1263
+ # Build parse options for imported CSS
1264
+ parse_opts = {
1265
+ import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1266
+ parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
1267
+ }
1162
1268
 
1163
- imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1164
-
1165
- # Wrap rules in @media if import had media query
1166
- if import_media_query_id
1167
- # Get the import's MediaQuery object
1168
- import_mq = @media_queries[import_media_query_id]
1169
-
1170
- imported_sheet.rules.each do |rule|
1171
- next unless rule.is_a?(Rule)
1172
-
1173
- if rule.media_query_id
1174
- # Rule already has a media query - need to combine them
1175
- # Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
1176
- # Result: screen and (max-width: 768px)
1177
- existing_mq = imported_sheet.media_queries[rule.media_query_id]
1178
-
1179
- # Parse combined media query to extract type and conditions
1180
- # The type is always the import's type (leftmost)
1181
- combined_type = import_mq.type
1182
- combined_conditions = if import_mq.conditions && existing_mq.conditions
1183
- "#{import_mq.conditions} and #{existing_mq.conditions}"
1184
- elsif import_mq.conditions
1185
- "#{import_mq.conditions} and #{existing_mq.text}"
1186
- elsif existing_mq.conditions
1187
- existing_mq.conditions
1188
- else
1189
- existing_mq.text
1190
- end
1191
-
1192
- # Create combined MediaQuery
1193
- combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
1194
- @media_queries << combined_mq
1195
- rule.media_query_id = @_next_media_query_id
1196
- @_next_media_query_id += 1
1197
- else
1198
- # Rule has no media query - just assign the import's media query
1199
- rule.media_query_id = import_media_query_id
1200
- end
1201
- end
1202
- end
1269
+ # If URL conversion is enabled (base_uri present), enable it for imported files too
1270
+ if opts[:base_uri]
1271
+ parse_opts[:absolute_paths] = true
1272
+ parse_opts[:base_uri] = imported_base_uri
1273
+ parse_opts[:uri_resolver] = opts[:uri_resolver]
1274
+ end
1203
1275
 
1204
- # Merge imported rules into this stylesheet
1205
- # Insert at current position (before any remaining local rules)
1206
- insert_position = import.id
1276
+ # Pass parent import's media query context to parser so nested imports can combine
1277
+ if import_media_query_id
1278
+ parent_mq = @media_queries[import_media_query_id]
1279
+ parse_opts[:parser][:parent_import_media_type] = parent_mq.type
1280
+ parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
1281
+ end
1207
1282
 
1208
- # Insert rules without modifying IDs (will renumber everything after all imports resolved)
1209
- imported_sheet.rules.each_with_index do |rule, idx|
1210
- @rules.insert(insert_position + idx, rule)
1211
- end
1283
+ imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1212
1284
 
1213
- # Merge media index
1214
- imported_sheet.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
1215
- if @media_index[media_sym]
1216
- @media_index[media_sym].concat(rule_ids)
1217
- else
1218
- @media_index[media_sym] = rule_ids.dup
1219
- end
1220
- end
1285
+ merge_imported_sheet!(imported_sheet, import)
1221
1286
 
1222
- # Merge selector_lists with offsetted IDs
1223
- list_id_offset = @_next_selector_list_id
1224
- imported_selector_lists = imported_sheet.instance_variable_get(:@_selector_lists)
1225
- if imported_selector_lists && !imported_selector_lists.empty?
1226
- imported_selector_lists.each do |list_id, rule_ids|
1227
- new_list_id = list_id + list_id_offset
1228
- @_selector_lists[new_list_id] = rule_ids.dup
1229
- end
1230
- @_next_selector_list_id = list_id_offset + imported_selector_lists.size
1231
- end
1287
+ # Merge charset (first one wins per CSS spec)
1288
+ @charset ||= imported_sheet.charset
1232
1289
 
1233
- # Merge media_query_lists with offsetted IDs
1234
- mq_list_id_offset = @_next_media_query_list_id
1235
- imported_mq_lists = imported_sheet.instance_variable_get(:@_media_query_lists)
1236
- if imported_mq_lists && !imported_mq_lists.empty?
1237
- imported_mq_lists.each do |list_id, mq_ids|
1238
- new_list_id = list_id + mq_list_id_offset
1239
- @_media_query_lists[new_list_id] = mq_ids.dup
1240
- end
1241
- @_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
1242
- end
1290
+ # Mark as resolved
1291
+ import.resolved = true
1292
+ end
1243
1293
 
1244
- # Merge charset (first one wins per CSS spec)
1245
- @charset ||= imported_sheet.instance_variable_get(:@charset)
1294
+ # Merge an already-parsed imported stylesheet's media queries, rules,
1295
+ # and selector/media-query lists into this one. Rules are inserted at
1296
+ # the importing @import statement's original document position; rule
1297
+ # ids (and selector_lists' rule-id references) are left unoffset here
1298
+ # and fixed up in one bulk pass once all imports are resolved, since
1299
+ # positional insertion shifts everything after it anyway.
1300
+ #
1301
+ # @return [void]
1302
+ def merge_imported_sheet!(imported_sheet, import)
1303
+ mq_id_offset = merge_media_queries!(imported_sheet.media_queries)
1304
+ rebase_imported_rules_media_query!(imported_sheet.rules, mq_id_offset, import.media_query_id)
1246
1305
 
1247
- # Mark as resolved
1248
- import.resolved = true
1306
+ insert_position = import.id
1307
+ imported_sheet.rules.each_with_index do |rule, idx|
1308
+ @rules.insert(insert_position + idx, rule)
1249
1309
  end
1250
1310
 
1251
- # Renumber all rule IDs to be sequential in document order
1252
- # This is O(n) and very fast (~1ms for 30k rules)
1253
- # Only needed if we actually resolved imports
1254
- return unless imports.length > 0
1311
+ merge_selector_lists!(imported_sheet.selector_lists)
1312
+ merge_media_query_lists!(imported_sheet.media_query_lists, mq_id_offset: mq_id_offset)
1313
+ end
1255
1314
 
1315
+ # Rebase every imported rule/at-rule's media_query_id onto this
1316
+ # stylesheet's own @media_queries (now that the imported sheet's own
1317
+ # media queries have been merged in), then, if the @import statement
1318
+ # itself had a media qualifier, combine it with (or assign it to) each
1319
+ # rule's media context.
1320
+ #
1321
+ # @return [void]
1322
+ def rebase_imported_rules_media_query!(imported_rules, mq_id_offset, import_media_query_id)
1323
+ imported_rules.each { |rule| rebase_rule!(rule, rule_id_offset: 0, mq_id_offset: mq_id_offset) }
1324
+
1325
+ return unless import_media_query_id
1326
+
1327
+ import_mq = @media_queries[import_media_query_id]
1328
+
1329
+ imported_rules.each do |rule|
1330
+ next unless rule.is_a?(Rule)
1331
+
1332
+ if rule.media_query_id
1333
+ # Rule already has a media query - need to combine them
1334
+ # Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
1335
+ # Result: screen and (max-width: 768px)
1336
+ existing_mq = @media_queries[rule.media_query_id]
1337
+
1338
+ # The type is always the import's type (leftmost)
1339
+ combined_type = import_mq.type
1340
+ combined_conditions = if import_mq.conditions && existing_mq.conditions
1341
+ "#{import_mq.conditions} and #{existing_mq.conditions}"
1342
+ elsif import_mq.conditions
1343
+ "#{import_mq.conditions} and #{existing_mq.text}"
1344
+ elsif existing_mq.conditions
1345
+ existing_mq.conditions
1346
+ else
1347
+ existing_mq.text
1348
+ end
1349
+
1350
+ combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
1351
+ @media_queries << combined_mq
1352
+ rule.media_query_id = @_next_media_query_id
1353
+ @_next_media_query_id += 1
1354
+ else
1355
+ # Rule has no media query - just assign the import's media query
1356
+ rule.media_query_id = import_media_query_id
1357
+ end
1358
+ end
1359
+ end
1360
+
1361
+ # After all imports for this call are resolved, renumber every rule
1362
+ # (including at-rules) and import statement placeholder to sequential
1363
+ # ids matching final document order, and propagate the same mapping to
1364
+ # selector_lists' rule-id references.
1365
+ #
1366
+ # @return [void]
1367
+ def renumber_after_import_resolution!
1256
1368
  # Single-pass renumbering: build old->new mapping while renumbering
1257
1369
  old_to_new_id = {}
1258
1370
  @rules.each_with_index do |rule, new_idx|
1259
- if rule.is_a?(Rule) || rule.is_a?(ImportStatement)
1260
- old_to_new_id[rule.id] = new_idx
1261
- rule.id = new_idx
1262
- end
1371
+ old_to_new_id[rule.id] = new_idx
1372
+ rule.id = new_idx
1263
1373
  end
1264
1374
 
1265
1375
  # Update rule IDs in selector_lists (only if we have any)
@@ -1276,32 +1386,6 @@ module Cataract
1276
1386
  @media_index = {}
1277
1387
  end
1278
1388
 
1279
- # Check if a rule matches any of the requested media queries
1280
- #
1281
- # @param rule_id [Integer] Rule ID to check
1282
- # @param query_media [Array<Symbol>] Media types to match
1283
- # @return [Boolean] true if rule appears in any of the requested media index entries
1284
- def rule_matches_media?(rule_id, query_media)
1285
- query_media.any? { |m| media_index[m]&.include?(rule_id) }
1286
- end
1287
-
1288
- # Check if a rule matches the specificity filter
1289
- #
1290
- # @param rule [Rule] Rule to check
1291
- # @param specificity [Integer, Range] Specificity filter
1292
- # @return [Boolean] true if rule matches specificity
1293
- def rule_matches_specificity?(rule, specificity)
1294
- # Skip rules with nil specificity (e.g., AtRule)
1295
- return false if rule.specificity.nil?
1296
-
1297
- case specificity
1298
- when Range
1299
- specificity.cover?(rule.specificity)
1300
- else
1301
- specificity == rule.specificity
1302
- end
1303
- end
1304
-
1305
1389
  # Clear memoized caches that can be lazily rebuilt.
1306
1390
  #
1307
1391
  # Call this method after any operation that modifies the stylesheet's rules
@@ -1350,19 +1434,5 @@ module Cataract
1350
1434
 
1351
1435
  props_by_media
1352
1436
  end
1353
-
1354
- # Check if a rule has a declaration matching property and/or value
1355
- #
1356
- # @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
1357
- # @param property [String, nil] Property name to match
1358
- # @param property_value [String, nil] Property value to match
1359
- # @return [Boolean] true if rule has matching declaration
1360
- def rule_matches_property?(rule, property, property_value)
1361
- rule.declarations.any? do |decl|
1362
- property_matches = property.nil? || decl.property == property
1363
- value_matches = property_value.nil? || decl.value == property_value
1364
- property_matches && value_matches
1365
- end
1366
- end
1367
1437
  end
1368
1438
  end