cataract 0.2.5 → 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.
@@ -169,16 +169,17 @@ module Cataract
169
169
  # @param source [Stylesheet] Source stylesheet being copied
170
170
  def initialize_copy(source)
171
171
  super
172
- @rules = source.instance_variable_get(:@rules).dup
173
- @media_queries = source.instance_variable_get(:@media_queries).dup
174
- @_next_media_query_id = source.instance_variable_get(:@_next_media_query_id)
175
- @media_index = source.instance_variable_get(:@media_index).transform_values(&:dup)
176
- @imports = source.instance_variable_get(:@imports).dup
177
- @_selector_lists = source.instance_variable_get(:@_selector_lists).transform_values(&:dup)
178
- @_next_selector_list_id = source.instance_variable_get(:@_next_selector_list_id)
179
- @_media_query_lists = source.instance_variable_get(:@_media_query_lists).transform_values(&:dup)
180
- @_next_media_query_list_id = source.instance_variable_get(:@_next_media_query_list_id)
181
- @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
182
183
  clear_memoized_caches
183
184
  @_hash = nil # Clear cached hash
184
185
  end
@@ -198,11 +199,12 @@ module Cataract
198
199
  #
199
200
  # @param filename [String] Path to the CSS file
200
201
  # @param base_dir [String] Base directory for resolving the filename (default: '.')
201
- # @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)
202
204
  # @return [Stylesheet] A new Stylesheet containing the parsed CSS
203
205
  def self.load_file(filename, base_dir = '.', **options)
204
206
  sheet = new(options)
205
- sheet.load_file(filename, base_dir)
207
+ sheet.load_file(filename, base_dir, options)
206
208
  sheet
207
209
  end
208
210
 
@@ -445,49 +447,26 @@ module Cataract
445
447
  #
446
448
  # @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
447
449
  # - :all - Output all rules including base rules and all media queries
448
- # - :screen, :print, etc. - Output only rules from specified media query
449
- # - [: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
450
452
  #
451
453
  # Important: When filtering to specific media types, base rules (rules not
452
- # inside any @media block) are NOT included. Only rules explicitly inside
453
- # 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.
454
456
  # @return [String] CSS string
455
457
  #
456
458
  # @example Get all CSS
457
459
  # sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
458
460
  # sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
459
461
  #
460
- # @example Filter to specific media type (excludes base rules)
461
- # sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
462
- # # 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
463
465
  #
464
466
  # @example Filter to multiple media types
465
467
  # sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
466
468
  def to_s(media: :all)
467
- which_media = media
468
- # Normalize to array for consistent filtering
469
- which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
470
-
471
- # If :all is present, return everything (no filtering)
472
- if which_media_array.include?(:all)
473
- Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
474
- else
475
- # Collect all rule IDs that match the requested media types
476
- matching_rule_ids = []
477
- mi = media_index # Build media_index if needed
478
- which_media_array.each do |media_sym|
479
- if mi[media_sym]
480
- matching_rule_ids.concat(mi[media_sym])
481
- end
482
- end
483
- matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
484
-
485
- # Build filtered rules array (keep original IDs, no recreation needed)
486
- filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
487
-
488
- # Serialize with filtered data
489
- Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
490
- end
469
+ Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
491
470
  end
492
471
  alias to_css to_s
493
472
 
@@ -497,10 +476,10 @@ module Cataract
497
476
  # Rules are formatted with each declaration on its own line, and media queries
498
477
  # are properly indented. Optionally filters output to specific media queries.
499
478
  #
500
- # @param which_media [Symbol, Array<Symbol>] Optional media filter (default: :all)
479
+ # @param media [Symbol, Array<Symbol>] Optional media filter (default: :all)
501
480
  # - :all - Output all rules including base rules and all media queries
502
- # - :screen, :print, etc. - Output only rules from specified media query
503
- # - [: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
504
483
  #
505
484
  # @return [String] Formatted CSS string
506
485
  #
@@ -508,43 +487,12 @@ module Cataract
508
487
  # sheet.to_formatted_s
509
488
  # # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
510
489
  #
511
- # @example Filter to specific media type
512
- # sheet.to_formatted_s(:print)
490
+ # @example Filter to specific media type (base rules still included)
491
+ # sheet.to_formatted_s(media: :print)
513
492
  #
514
493
  # @see #to_s For compact single-line output
515
494
  def to_formatted_s(media: :all)
516
- which_media = media
517
- # Normalize to array for consistent filtering
518
- which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
519
-
520
- # If :all is present, return everything (no filtering)
521
- if which_media_array.include?(:all)
522
- Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
523
- else
524
- # Collect all rule IDs that match the requested media types
525
- matching_rule_ids = []
526
- mi = media_index # Build media_index if needed
527
-
528
- # Include rules not in any media query (they apply to all media)
529
- media_rule_ids = mi.values.flatten.uniq
530
- all_rule_ids = (0...@rules.length).to_a
531
- non_media_rule_ids = all_rule_ids - media_rule_ids
532
- matching_rule_ids.concat(non_media_rule_ids)
533
-
534
- # Include rules from requested media types
535
- which_media_array.each do |media_sym|
536
- if mi[media_sym]
537
- matching_rule_ids.concat(mi[media_sym])
538
- end
539
- end
540
- matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
541
-
542
- # Build filtered rules array (keep original IDs, no recreation needed)
543
- filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
544
-
545
- # Serialize with filtered data
546
- Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
547
- end
495
+ Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
548
496
  end
549
497
 
550
498
  # Get number of rules
@@ -578,14 +526,15 @@ module Cataract
578
526
  #
579
527
  # @param filename [String] Path to the CSS file
580
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: [])
581
530
  # @return [self] Returns self for method chaining
582
- def load_file(filename, base_dir = '.', _media_types = :all)
531
+ def load_file(filename, base_dir = '.', options = {})
583
532
  # Normalize file path and convert to file:// URI
584
533
  file_path = File.expand_path(filename, base_dir)
585
534
  file_uri = "file://#{file_path}"
586
535
 
587
536
  # Delegate to load_uri which handles imports and base_path
588
- load_uri(file_uri)
537
+ load_uri(file_uri, options)
589
538
  end
590
539
 
591
540
  # Load CSS from a URI and add to this stylesheet.
@@ -595,20 +544,25 @@ module Cataract
595
544
  # @return [self] Returns self for method chaining
596
545
  def load_uri(uri, options = {})
597
546
  require 'uri'
598
- require 'net/http'
599
547
 
600
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
601
559
  css_content = nil
602
560
  file_path = nil
603
561
 
604
562
  case uri_obj.scheme
605
563
  when 'http', 'https'
606
- response = Net::HTTP.get_response(uri_obj)
607
- unless response.is_a?(Net::HTTPSuccess)
608
- raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
609
- end
610
-
611
- css_content = response.body
564
+ ImportResolver.validate_url(uri, opts)
565
+ css_content = fetcher.call(uri, opts)
612
566
  when 'file', nil
613
567
  # file:// URI or relative path
614
568
  path = uri_obj.scheme == 'file' ? uri_obj.path : uri
@@ -625,7 +579,9 @@ module Cataract
625
579
  @options[:import] = @options[:import].merge(base_path: file_dir)
626
580
  end
627
581
 
628
- 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)
629
585
  else
630
586
  raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
631
587
  end
@@ -726,9 +682,7 @@ module Cataract
726
682
  # Clean up empty media_index entries
727
683
  @media_index.delete_if { |_media, ids| ids.empty? }
728
684
 
729
- # Clean up unused MediaQuery objects (those not referenced by any rule)
730
- used_mq_ids = @rules.filter_map { |r| r.media_query_id if r.is_a?(Rule) }.to_set
731
- @media_queries.select! { |mq| used_mq_ids.include?(mq.id) }
685
+ compact_media_queries!
732
686
 
733
687
  # Update rule IDs in remaining rules
734
688
  @rules.each_with_index { |rule, new_id| rule.id = new_id }
@@ -761,119 +715,12 @@ module Cataract
761
715
  effective_base_dir = base_dir || @options[:base_dir]
762
716
  effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
763
717
 
764
- # Get current rule ID offset
765
- offset = @_last_rule_id || 0
766
-
767
- # Build parser options with URL conversion settings
768
- parse_options = @parser_options.dup
769
- if effective_absolute_paths && effective_base_uri
770
- parse_options[:base_uri] = effective_base_uri
771
- parse_options[:absolute_paths] = true
772
- parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
773
- end
718
+ parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
774
719
 
775
720
  # Parse CSS first (this extracts @import statements into result[:imports])
776
721
  result = Cataract._parse_css(css, parse_options)
777
722
 
778
- # Merge selector_lists with offsetted IDs
779
- list_id_offset = @_next_selector_list_id
780
- if result[:_selector_lists] && !result[:_selector_lists].empty?
781
- result[:_selector_lists].each do |list_id, rule_ids|
782
- new_list_id = list_id + list_id_offset
783
- offsetted_rule_ids = rule_ids.map { |id| id + offset }
784
- @_selector_lists[new_list_id] = offsetted_rule_ids
785
- end
786
- @_next_selector_list_id = list_id_offset + result[:_selector_lists].size
787
- end
788
-
789
- # Merge media_query_lists with offsetted IDs
790
- media_query_id_offset = @_next_media_query_id
791
- mq_list_id_offset = @_next_media_query_list_id
792
- if result[:_media_query_lists] && !result[:_media_query_lists].empty?
793
- result[:_media_query_lists].each do |list_id, mq_ids|
794
- new_list_id = list_id + mq_list_id_offset
795
- offsetted_mq_ids = mq_ids.map { |id| id + media_query_id_offset }
796
- @_media_query_lists[new_list_id] = offsetted_mq_ids
797
- end
798
- @_next_media_query_list_id = mq_list_id_offset + result[:_media_query_lists].size
799
- end
800
-
801
- # Merge rules with offsetted IDs
802
- new_rules = result[:rules]
803
- new_rules.each do |rule|
804
- rule.id += offset
805
- # Update selector_list_id to point to offsetted list (only for Rule, not AtRule)
806
- if rule.is_a?(Rule) && rule.selector_list_id
807
- rule.selector_list_id += list_id_offset
808
- end
809
- # Update media_query_id to point to offsetted MediaQuery
810
- if rule.is_a?(Rule) && rule.media_query_id
811
- rule.media_query_id += media_query_id_offset
812
- end
813
- @rules << rule
814
- end
815
-
816
- # Merge media_index with offsetted IDs
817
- result[:_media_index].each do |media_sym, rule_ids|
818
- offsetted_ids = rule_ids.map { |id| id + offset }
819
- if @media_index[media_sym]
820
- @media_index[media_sym].concat(offsetted_ids)
821
- else
822
- @media_index[media_sym] = offsetted_ids
823
- end
824
- end
825
-
826
- # Merge media_queries with offsetted IDs
827
- if result[:media_queries]
828
- result[:media_queries].each do |mq|
829
- mq.id += media_query_id_offset
830
- @media_queries << mq
831
- end
832
- @_next_media_query_id += result[:media_queries].length
833
- end
834
-
835
- # Update last rule ID
836
- @_last_rule_id = offset + new_rules.length
837
-
838
- # Merge imports with offsetted IDs
839
- if result[:imports]
840
- new_imports = result[:imports]
841
- new_imports.each do |import|
842
- import.id += offset
843
- # Update media_query_id to point to offsetted MediaQuery
844
- if import.media_query_id
845
- import.media_query_id += media_query_id_offset
846
- end
847
- @imports << import
848
- end
849
-
850
- # Resolve imports if configured
851
- if @options[:import]
852
- # Extract imported_urls and depth from options
853
- if @options[:import].is_a?(Hash)
854
- imported_urls = @options[:import][:imported_urls] || []
855
- depth = @options[:import][:depth] || 0
856
- else
857
- imported_urls = []
858
- depth = 0
859
- end
860
-
861
- # Build import options with base_uri/base_dir for URL resolution
862
- import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
863
- import_opts[:base_uri] = effective_base_uri if effective_base_uri
864
- import_opts[:base_path] = effective_base_dir if effective_base_dir
865
-
866
- resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
867
- end
868
- end
869
-
870
- # Set charset if not already set
871
- @charset ||= result[:charset]
872
-
873
- # Track if we have any nesting (for serialization optimization)
874
- @_has_nesting = result[:_has_nesting]
875
-
876
- clear_memoized_caches
723
+ merge_parsed_block!(result, effective_base_uri, effective_base_dir)
877
724
 
878
725
  self
879
726
  end
@@ -924,7 +771,7 @@ module Cataract
924
771
  def ==(other)
925
772
  return false unless other.is_a?(Stylesheet)
926
773
  return false unless rules == other.rules
927
- return false unless @media_queries == other.instance_variable_get(:@media_queries)
774
+ return false unless @media_queries == other.media_queries
928
775
 
929
776
  true
930
777
  end
@@ -967,9 +814,9 @@ module Cataract
967
814
  # @return [self] Returns self for method chaining
968
815
  def flatten!
969
816
  flattened = Cataract.flatten(self)
970
- @rules = flattened.instance_variable_get(:@rules)
971
- @media_index = flattened.instance_variable_get(:@media_index)
972
- @_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
973
820
  self
974
821
  end
975
822
  alias cascade! flatten!
@@ -990,29 +837,23 @@ module Cataract
990
837
  def concat(other)
991
838
  raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
992
839
 
993
- # Get the current offset for rule IDs
840
+ # Get the current offset for rule/media-query/selector-list IDs
994
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)
995
845
 
996
- # Add rules with updated IDs
846
+ # Add rules with updated IDs and cross-references
997
847
  other.rules.each do |rule|
998
848
  new_rule = rule.dup
999
- 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)
1000
850
  @rules << new_rule
1001
851
  end
1002
852
 
1003
- # Merge media_index with offsetted IDs
1004
- other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
1005
- offsetted_ids = rule_ids.map { |id| id + offset }
1006
- if @media_index[media_sym]
1007
- @media_index[media_sym].concat(offsetted_ids)
1008
- else
1009
- @media_index[media_sym] = offsetted_ids
1010
- end
1011
- end
853
+ merge_media_index!(other.media_index_cache, rule_id_offset: offset)
1012
854
 
1013
855
  # Update nesting flag if other has nesting
1014
- other_has_nesting = other.instance_variable_get(:@_has_nesting)
1015
- @_has_nesting = true if other_has_nesting
856
+ @_has_nesting = true if other.has_nesting
1016
857
 
1017
858
  clear_memoized_caches
1018
859
 
@@ -1057,7 +898,7 @@ module Cataract
1057
898
  result.rules.delete_at(idx)
1058
899
 
1059
900
  # Update media_index: remove this rule ID and decrement higher IDs
1060
- result.instance_variable_get(:@media_index).each_value do |ids|
901
+ result.media_index_cache.each_value do |ids|
1061
902
  ids.delete(idx)
1062
903
  ids.map! { |id| id > idx ? id - 1 : id }
1063
904
  end
@@ -1067,58 +908,301 @@ module Cataract
1067
908
  result.rules.each_with_index { |rule, new_id| rule.id = new_id }
1068
909
 
1069
910
  # Clean up empty media_index entries
1070
- 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
1071
1027
 
1072
- # Clean up unused MediaQuery objects and rebuild ID mapping
1073
- used_mq_ids = Set.new
1074
- result.rules.each do |rule|
1075
- used_mq_ids << rule.media_query_id if rule.respond_to?(:media_query_id) && rule.media_query_id
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
1076
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
1114
+
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
1129
+ end
1130
+
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
1077
1155
 
1078
- # Build old_id => new_id mapping
1079
- # Keep MediaQuery objects that are used, maintaining their IDs
1080
1156
  old_to_new_mq_id = {}
1081
1157
  kept_mqs = []
1082
- result.instance_variable_get(:@media_queries).each do |mq|
1158
+ @media_queries.each do |mq|
1083
1159
  next unless used_mq_ids.include?(mq.id)
1084
1160
 
1085
1161
  old_to_new_mq_id[mq.id] = kept_mqs.size
1086
1162
  mq.id = kept_mqs.size
1087
1163
  kept_mqs << mq
1088
1164
  end
1165
+ @media_queries = kept_mqs
1089
1166
 
1090
- # Replace media_queries array with kept ones
1091
- result.instance_variable_set(:@media_queries, kept_mqs)
1092
-
1093
- # Update media_query_id references in rules
1094
- result.rules.each do |rule|
1095
- if rule.respond_to?(:media_query_id) && rule.media_query_id
1096
- rule.media_query_id = old_to_new_mq_id[rule.media_query_id]
1097
- 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
1098
1169
  end
1099
1170
 
1100
- # Update media_query_lists with new IDs
1101
- result.instance_variable_get(:@_media_query_lists).each_value do |mq_ids|
1102
- mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact!
1103
- 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
1104
1174
 
1105
- # Clean up media_query_lists that are now empty
1106
- 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)
1107
1187
 
1108
- # Clear memoized cache
1109
- result.instance_variable_set(:@selectors, nil)
1110
- result.instance_variable_set(:@_hash, nil)
1188
+ mi = media_index # Build media_index if needed
1111
1189
 
1112
- result
1113
- 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) }
1114
1197
 
1115
- 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
1116
1203
 
1117
- # @private
1118
- # Internal index mapping media query symbols to rule IDs for efficient filtering.
1119
- # This is an implementation detail and should not be relied upon by external code.
1120
- # @return [Hash<Symbol, Array<Integer>>]
1121
- attr_reader :_media_index
1204
+ matching_rule_ids.sort!.map! { |rule_id| @rules[rule_id] }
1205
+ end
1122
1206
 
1123
1207
  # Resolve @import statements by fetching and merging imported stylesheets
1124
1208
  #
@@ -1142,146 +1226,150 @@ module Cataract
1142
1226
  imports.each do |import|
1143
1227
  next if import.resolved # Skip already resolved imports
1144
1228
 
1145
- url = import.url
1146
- import_media_query_id = import.media_query_id
1229
+ resolve_single_import!(import, opts, fetcher, imported_urls, depth)
1230
+ end
1147
1231
 
1148
- # Validate URL
1149
- 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
1150
1237
 
1151
- # Check for circular references
1152
- 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
1153
1245
 
1154
- # Fetch imported CSS
1155
- imported_css = fetcher.call(url, opts)
1246
+ # Validate URL
1247
+ ImportResolver.validate_url(url, opts)
1156
1248
 
1157
- # Parse imported CSS recursively
1158
- imported_urls_copy = imported_urls.dup
1159
- imported_urls_copy << url
1249
+ # Check for circular references
1250
+ raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
1160
1251
 
1161
- # Determine the base URI for the imported file
1162
- # This becomes the new base for resolving relative URLs in the imported CSS
1163
- 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)
1164
1254
 
1165
- # Build parse options for imported CSS
1166
- parse_opts = {
1167
- import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1168
- parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
1169
- }
1255
+ # Parse imported CSS recursively
1256
+ imported_urls_copy = imported_urls.dup
1257
+ imported_urls_copy << url
1170
1258
 
1171
- # If URL conversion is enabled (base_uri present), enable it for imported files too
1172
- if opts[:base_uri]
1173
- parse_opts[:absolute_paths] = true
1174
- parse_opts[:base_uri] = imported_base_uri
1175
- parse_opts[:uri_resolver] = opts[:uri_resolver]
1176
- 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
1177
1262
 
1178
- # Pass parent import's media query context to parser so nested imports can combine
1179
- if import_media_query_id
1180
- parent_mq = @media_queries[import_media_query_id]
1181
- parse_opts[:parser][:parent_import_media_type] = parent_mq.type
1182
- parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
1183
- 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
+ }
1184
1268
 
1185
- imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1186
-
1187
- # Wrap rules in @media if import had media query
1188
- if import_media_query_id
1189
- # Get the import's MediaQuery object
1190
- import_mq = @media_queries[import_media_query_id]
1191
-
1192
- imported_sheet.rules.each do |rule|
1193
- next unless rule.is_a?(Rule)
1194
-
1195
- if rule.media_query_id
1196
- # Rule already has a media query - need to combine them
1197
- # Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
1198
- # Result: screen and (max-width: 768px)
1199
- existing_mq = imported_sheet.media_queries[rule.media_query_id]
1200
-
1201
- # Parse combined media query to extract type and conditions
1202
- # The type is always the import's type (leftmost)
1203
- combined_type = import_mq.type
1204
- combined_conditions = if import_mq.conditions && existing_mq.conditions
1205
- "#{import_mq.conditions} and #{existing_mq.conditions}"
1206
- elsif import_mq.conditions
1207
- "#{import_mq.conditions} and #{existing_mq.text}"
1208
- elsif existing_mq.conditions
1209
- existing_mq.conditions
1210
- else
1211
- existing_mq.text
1212
- end
1213
-
1214
- # Create combined MediaQuery
1215
- combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
1216
- @media_queries << combined_mq
1217
- rule.media_query_id = @_next_media_query_id
1218
- @_next_media_query_id += 1
1219
- else
1220
- # Rule has no media query - just assign the import's media query
1221
- rule.media_query_id = import_media_query_id
1222
- end
1223
- end
1224
- 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
1225
1275
 
1226
- # Merge imported rules into this stylesheet
1227
- # Insert at current position (before any remaining local rules)
1228
- 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
1229
1282
 
1230
- # Insert rules without modifying IDs (will renumber everything after all imports resolved)
1231
- imported_sheet.rules.each_with_index do |rule, idx|
1232
- @rules.insert(insert_position + idx, rule)
1233
- end
1283
+ imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1234
1284
 
1235
- # Merge media index
1236
- imported_sheet.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
1237
- if @media_index[media_sym]
1238
- @media_index[media_sym].concat(rule_ids)
1239
- else
1240
- @media_index[media_sym] = rule_ids.dup
1241
- end
1242
- end
1285
+ merge_imported_sheet!(imported_sheet, import)
1243
1286
 
1244
- # Merge selector_lists with offsetted IDs
1245
- list_id_offset = @_next_selector_list_id
1246
- imported_selector_lists = imported_sheet.instance_variable_get(:@_selector_lists)
1247
- if imported_selector_lists && !imported_selector_lists.empty?
1248
- imported_selector_lists.each do |list_id, rule_ids|
1249
- new_list_id = list_id + list_id_offset
1250
- @_selector_lists[new_list_id] = rule_ids.dup
1251
- end
1252
- @_next_selector_list_id = list_id_offset + imported_selector_lists.size
1253
- end
1287
+ # Merge charset (first one wins per CSS spec)
1288
+ @charset ||= imported_sheet.charset
1254
1289
 
1255
- # Merge media_query_lists with offsetted IDs
1256
- mq_list_id_offset = @_next_media_query_list_id
1257
- imported_mq_lists = imported_sheet.instance_variable_get(:@_media_query_lists)
1258
- if imported_mq_lists && !imported_mq_lists.empty?
1259
- imported_mq_lists.each do |list_id, mq_ids|
1260
- new_list_id = list_id + mq_list_id_offset
1261
- @_media_query_lists[new_list_id] = mq_ids.dup
1262
- end
1263
- @_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
1264
- end
1290
+ # Mark as resolved
1291
+ import.resolved = true
1292
+ end
1265
1293
 
1266
- # Merge charset (first one wins per CSS spec)
1267
- @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)
1268
1305
 
1269
- # Mark as resolved
1270
- 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)
1271
1309
  end
1272
1310
 
1273
- # Renumber all rule IDs to be sequential in document order
1274
- # This is O(n) and very fast (~1ms for 30k rules)
1275
- # Only needed if we actually resolved imports
1276
- 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
1277
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!
1278
1368
  # Single-pass renumbering: build old->new mapping while renumbering
1279
1369
  old_to_new_id = {}
1280
1370
  @rules.each_with_index do |rule, new_idx|
1281
- if rule.is_a?(Rule) || rule.is_a?(ImportStatement)
1282
- old_to_new_id[rule.id] = new_idx
1283
- rule.id = new_idx
1284
- end
1371
+ old_to_new_id[rule.id] = new_idx
1372
+ rule.id = new_idx
1285
1373
  end
1286
1374
 
1287
1375
  # Update rule IDs in selector_lists (only if we have any)
@@ -1298,32 +1386,6 @@ module Cataract
1298
1386
  @media_index = {}
1299
1387
  end
1300
1388
 
1301
- # Check if a rule matches any of the requested media queries
1302
- #
1303
- # @param rule_id [Integer] Rule ID to check
1304
- # @param query_media [Array<Symbol>] Media types to match
1305
- # @return [Boolean] true if rule appears in any of the requested media index entries
1306
- def rule_matches_media?(rule_id, query_media)
1307
- query_media.any? { |m| media_index[m]&.include?(rule_id) }
1308
- end
1309
-
1310
- # Check if a rule matches the specificity filter
1311
- #
1312
- # @param rule [Rule] Rule to check
1313
- # @param specificity [Integer, Range] Specificity filter
1314
- # @return [Boolean] true if rule matches specificity
1315
- def rule_matches_specificity?(rule, specificity)
1316
- # Skip rules with nil specificity (e.g., AtRule)
1317
- return false if rule.specificity.nil?
1318
-
1319
- case specificity
1320
- when Range
1321
- specificity.cover?(rule.specificity)
1322
- else
1323
- specificity == rule.specificity
1324
- end
1325
- end
1326
-
1327
1389
  # Clear memoized caches that can be lazily rebuilt.
1328
1390
  #
1329
1391
  # Call this method after any operation that modifies the stylesheet's rules
@@ -1372,19 +1434,5 @@ module Cataract
1372
1434
 
1373
1435
  props_by_media
1374
1436
  end
1375
-
1376
- # Check if a rule has a declaration matching property and/or value
1377
- #
1378
- # @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
1379
- # @param property [String, nil] Property name to match
1380
- # @param property_value [String, nil] Property value to match
1381
- # @return [Boolean] true if rule has matching declaration
1382
- def rule_matches_property?(rule, property, property_value)
1383
- rule.declarations.any? do |decl|
1384
- property_matches = property.nil? || decl.property == property
1385
- value_matches = property_value.nil? || decl.value == property_value
1386
- property_matches && value_matches
1387
- end
1388
- end
1389
1437
  end
1390
1438
  end