cataract 0.2.5 → 0.4.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -6
  4. data/.rubocop_todo.yml +11 -19
  5. data/BENCHMARKS.md +40 -40
  6. data/CHANGELOG.md +28 -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 +312 -550
  11. data/ext/cataract/cataract.h +126 -16
  12. data/ext/cataract/css_parser.c +782 -806
  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 +26 -10
  18. data/lib/cataract/import_resolver.rb +36 -16
  19. data/lib/cataract/native.rb +30 -0
  20. data/lib/cataract/pure/byte_constants.rb +70 -67
  21. data/lib/cataract/pure/declarations.rb +125 -0
  22. data/lib/cataract/pure/flatten.rb +1195 -1182
  23. data/lib/cataract/pure/parser.rb +1721 -1652
  24. data/lib/cataract/pure/serializer.rb +575 -710
  25. data/lib/cataract/pure/specificity.rb +152 -175
  26. data/lib/cataract/pure.rb +73 -108
  27. data/lib/cataract/rule.rb +6 -3
  28. data/lib/cataract/stylesheet.rb +475 -415
  29. data/lib/cataract/version.rb +1 -1
  30. data/lib/cataract.rb +41 -30
  31. data/lib/tasks/profile.rake +6 -3
  32. metadata +4 -14
  33. data/ext/cataract/import_scanner.c +0 -174
  34. data/ext/cataract_old/cataract.c +0 -393
  35. data/ext/cataract_old/cataract.h +0 -250
  36. data/ext/cataract_old/css_parser.c +0 -933
  37. data/ext/cataract_old/extconf.rb +0 -67
  38. data/ext/cataract_old/import_scanner.c +0 -174
  39. data/ext/cataract_old/merge.c +0 -776
  40. data/ext/cataract_old/shorthand_expander.c +0 -902
  41. data/ext/cataract_old/specificity.c +0 -213
  42. data/ext/cataract_old/stylesheet.c +0 -290
  43. data/ext/cataract_old/value_splitter.c +0 -116
  44. data/lib/cataract/pure/helpers.rb +0 -35
@@ -112,6 +112,13 @@ module Cataract
112
112
  # Type validation
113
113
  raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
114
114
 
115
+ # Which backend (Cataract::Backends::Native / ::Pure) produced this
116
+ # stylesheet - defaults to whichever this process picked as active.
117
+ # Not part of the public options contract (used internally by
118
+ # Backends::Native.parse / Backends::Pure.parse), so it's popped off
119
+ # before @options is built.
120
+ @backend = options.delete(:backend) || Cataract::Backends.active
121
+
115
122
  # Support :imports as alias for :import (backwards compatibility)
116
123
  options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
117
124
 
@@ -169,16 +176,18 @@ module Cataract
169
176
  # @param source [Stylesheet] Source stylesheet being copied
170
177
  def initialize_copy(source)
171
178
  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
179
+ @backend = source.backend
180
+ @options = source.options.dup
181
+ @rules = source.rules.dup
182
+ @media_queries = source.media_queries.dup
183
+ @_next_media_query_id = source.next_media_query_id
184
+ @media_index = source.media_index_cache.transform_values(&:dup)
185
+ @imports = source.imports.dup
186
+ @_selector_lists = source.selector_lists.transform_values(&:dup)
187
+ @_next_selector_list_id = source.next_selector_list_id
188
+ @_media_query_lists = source.media_query_lists.transform_values(&:dup)
189
+ @_next_media_query_list_id = source.next_media_query_list_id
190
+ @parser_options = source.parser_options.dup
182
191
  clear_memoized_caches
183
192
  @_hash = nil # Clear cached hash
184
193
  end
@@ -198,11 +207,12 @@ module Cataract
198
207
  #
199
208
  # @param filename [String] Path to the CSS file
200
209
  # @param base_dir [String] Base directory for resolving the filename (default: '.')
201
- # @param options [Hash] Options passed to Stylesheet.new
210
+ # @param options [Hash] Options passed to Stylesheet.new, and to load_file
211
+ # (e.g. dangerous_path_prefixes: [] to load a path load_file blocks by default)
202
212
  # @return [Stylesheet] A new Stylesheet containing the parsed CSS
203
213
  def self.load_file(filename, base_dir = '.', **options)
204
214
  sheet = new(options)
205
- sheet.load_file(filename, base_dir)
215
+ sheet.load_file(filename, base_dir, options)
206
216
  sheet
207
217
  end
208
218
 
@@ -445,49 +455,26 @@ module Cataract
445
455
  #
446
456
  # @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
447
457
  # - :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
458
+ # - :screen, :print, etc. - Output base rules plus rules from the specified media query
459
+ # - [:screen, :print] - Output base rules plus rules from multiple media queries
450
460
  #
451
461
  # 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.
462
+ # inside any @media block) are always included, since they apply regardless
463
+ # of media context. Only rules from OTHER @media queries are excluded.
454
464
  # @return [String] CSS string
455
465
  #
456
466
  # @example Get all CSS
457
467
  # sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
458
468
  # sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
459
469
  #
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
470
+ # @example Filter to specific media type (base rules still included)
471
+ # sheet.to_s(media: :print) # => "body { color: black; } @media print { .footer { color: red; } }"
472
+ # # Note: base rules like "body { color: black; }" apply during print too, so they're kept
463
473
  #
464
474
  # @example Filter to multiple media types
465
475
  # sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
466
476
  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
477
+ @backend.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
491
478
  end
492
479
  alias to_css to_s
493
480
 
@@ -497,10 +484,10 @@ module Cataract
497
484
  # Rules are formatted with each declaration on its own line, and media queries
498
485
  # are properly indented. Optionally filters output to specific media queries.
499
486
  #
500
- # @param which_media [Symbol, Array<Symbol>] Optional media filter (default: :all)
487
+ # @param media [Symbol, Array<Symbol>] Optional media filter (default: :all)
501
488
  # - :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
489
+ # - :screen, :print, etc. - Output base rules plus rules from the specified media query
490
+ # - [:screen, :print] - Output base rules plus rules from multiple media queries
504
491
  #
505
492
  # @return [String] Formatted CSS string
506
493
  #
@@ -508,43 +495,12 @@ module Cataract
508
495
  # sheet.to_formatted_s
509
496
  # # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
510
497
  #
511
- # @example Filter to specific media type
512
- # sheet.to_formatted_s(:print)
498
+ # @example Filter to specific media type (base rules still included)
499
+ # sheet.to_formatted_s(media: :print)
513
500
  #
514
501
  # @see #to_s For compact single-line output
515
502
  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
503
+ @backend.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
548
504
  end
549
505
 
550
506
  # Get number of rules
@@ -578,14 +534,15 @@ module Cataract
578
534
  #
579
535
  # @param filename [String] Path to the CSS file
580
536
  # @param base_dir [String] Base directory for resolving the filename (default: '.')
537
+ # @param options [Hash] Passed through to load_uri (e.g. dangerous_path_prefixes: [])
581
538
  # @return [self] Returns self for method chaining
582
- def load_file(filename, base_dir = '.', _media_types = :all)
539
+ def load_file(filename, base_dir = '.', options = {})
583
540
  # Normalize file path and convert to file:// URI
584
541
  file_path = File.expand_path(filename, base_dir)
585
542
  file_uri = "file://#{file_path}"
586
543
 
587
544
  # Delegate to load_uri which handles imports and base_path
588
- load_uri(file_uri)
545
+ load_uri(file_uri, options)
589
546
  end
590
547
 
591
548
  # Load CSS from a URI and add to this stylesheet.
@@ -595,20 +552,25 @@ module Cataract
595
552
  # @return [self] Returns self for method chaining
596
553
  def load_uri(uri, options = {})
597
554
  require 'uri'
598
- require 'net/http'
599
555
 
600
556
  uri_obj = URI(uri)
557
+ # Reuse the same validation and fetch collaborator @import resolution
558
+ # uses (ImportResolver, via DefaultFetcher/open-uri) instead of a
559
+ # second, separate Net::HTTP implementation with no validation at all -
560
+ # that duplicate implementation also didn't follow redirects, unlike
561
+ # this one. LOAD_DEFAULTS (rather than SAFE_DEFAULTS) reflects that the
562
+ # caller chose this exact URI themselves, so http/any-extension are
563
+ # allowed by default; dangerous_path_prefixes still applies unless the
564
+ # caller overrides it (e.g. dangerous_path_prefixes: []).
565
+ opts = ImportResolver.normalize_options(options, defaults: ImportResolver::LOAD_DEFAULTS)
566
+ fetcher = opts[:fetcher] || @options[:import_fetcher] || ImportResolver::DefaultFetcher.new
601
567
  css_content = nil
602
568
  file_path = nil
603
569
 
604
570
  case uri_obj.scheme
605
571
  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
572
+ ImportResolver.validate_url(uri, opts)
573
+ css_content = fetcher.call(uri, opts)
612
574
  when 'file', nil
613
575
  # file:// URI or relative path
614
576
  path = uri_obj.scheme == 'file' ? uri_obj.path : uri
@@ -625,7 +587,9 @@ module Cataract
625
587
  @options[:import] = @options[:import].merge(base_path: file_dir)
626
588
  end
627
589
 
628
- css_content = File.read(file_path)
590
+ file_uri = "file://#{file_path}"
591
+ ImportResolver.validate_url(file_uri, opts)
592
+ css_content = fetcher.call(file_uri, opts)
629
593
  else
630
594
  raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
631
595
  end
@@ -726,9 +690,7 @@ module Cataract
726
690
  # Clean up empty media_index entries
727
691
  @media_index.delete_if { |_media, ids| ids.empty? }
728
692
 
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) }
693
+ compact_media_queries!
732
694
 
733
695
  # Update rule IDs in remaining rules
734
696
  @rules.each_with_index { |rule, new_id| rule.id = new_id }
@@ -761,119 +723,12 @@ module Cataract
761
723
  effective_base_dir = base_dir || @options[:base_dir]
762
724
  effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
763
725
 
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
726
+ parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
774
727
 
775
728
  # Parse CSS first (this extracts @import statements into result[:imports])
776
- result = Cataract._parse_css(css, parse_options)
729
+ result = @backend.parse(css, parse_options)
777
730
 
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
731
+ merge_parsed_block!(result, effective_base_uri, effective_base_dir)
877
732
 
878
733
  self
879
734
  end
@@ -924,7 +779,7 @@ module Cataract
924
779
  def ==(other)
925
780
  return false unless other.is_a?(Stylesheet)
926
781
  return false unless rules == other.rules
927
- return false unless @media_queries == other.instance_variable_get(:@media_queries)
782
+ return false unless @media_queries == other.media_queries
928
783
 
929
784
  true
930
785
  end
@@ -947,7 +802,9 @@ module Cataract
947
802
  #
948
803
  # @return [Stylesheet] New stylesheet with cascade applied
949
804
  def flatten
950
- Cataract.flatten(self)
805
+ result = @backend.flatten(self)
806
+ result.backend = @backend
807
+ result
951
808
  end
952
809
  alias cascade flatten
953
810
 
@@ -966,10 +823,10 @@ module Cataract
966
823
  #
967
824
  # @return [self] Returns self for method chaining
968
825
  def flatten!
969
- 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)
826
+ flattened = @backend.flatten(self)
827
+ @rules = flattened.rules
828
+ @media_index = flattened.media_index_cache
829
+ @_has_nesting = flattened.has_nesting
973
830
  self
974
831
  end
975
832
  alias cascade! flatten!
@@ -990,29 +847,23 @@ module Cataract
990
847
  def concat(other)
991
848
  raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
992
849
 
993
- # Get the current offset for rule IDs
850
+ # Get the current offset for rule/media-query/selector-list IDs
994
851
  offset = @rules.length
852
+ list_id_offset = merge_selector_lists!(other.selector_lists, rule_id_offset: offset)
853
+ mq_id_offset = merge_media_queries!(other.media_queries)
854
+ merge_media_query_lists!(other.media_query_lists, mq_id_offset: mq_id_offset)
995
855
 
996
- # Add rules with updated IDs
856
+ # Add rules with updated IDs and cross-references
997
857
  other.rules.each do |rule|
998
858
  new_rule = rule.dup
999
- new_rule.id = @rules.length
859
+ rebase_rule!(new_rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
1000
860
  @rules << new_rule
1001
861
  end
1002
862
 
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
863
+ merge_media_index!(other.media_index_cache, rule_id_offset: offset)
1012
864
 
1013
865
  # 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
866
+ @_has_nesting = true if other.has_nesting
1016
867
 
1017
868
  clear_memoized_caches
1018
869
 
@@ -1057,7 +908,7 @@ module Cataract
1057
908
  result.rules.delete_at(idx)
1058
909
 
1059
910
  # Update media_index: remove this rule ID and decrement higher IDs
1060
- result.instance_variable_get(:@media_index).each_value do |ids|
911
+ result.media_index_cache.each_value do |ids|
1061
912
  ids.delete(idx)
1062
913
  ids.map! { |id| id > idx ? id - 1 : id }
1063
914
  end
@@ -1067,58 +918,302 @@ module Cataract
1067
918
  result.rules.each_with_index { |rule, new_id| rule.id = new_id }
1068
919
 
1069
920
  # Clean up empty media_index entries
1070
- result.instance_variable_get(:@media_index).delete_if { |_media, ids| ids.empty? }
921
+ result.media_index_cache.delete_if { |_media, ids| ids.empty? }
922
+
923
+ result.send(:compact_media_queries!)
924
+
925
+ # Clear memoized cache
926
+ result.send(:clear_memoized_caches)
927
+ result._hash = nil
928
+
929
+ result
930
+ end
931
+
932
+ protected
933
+
934
+ # Internal accessors for another Stylesheet instance's private state.
935
+ # Protected (not public) so this stays reachable from sibling instances
936
+ # within this class (dup/clone, concat, resolve_imports, -) without
937
+ # becoming part of the public API - and not plain attr_readers, since
938
+ # several ivar names would collide with unrelated public methods
939
+ # (notably #media_index, which lazily rebuilds rather than exposing the
940
+ # raw cache these need).
941
+
942
+ attr_reader :options, :parser_options
943
+ attr_accessor :backend
944
+
945
+ def next_media_query_id
946
+ @_next_media_query_id
947
+ end
948
+
949
+ def next_selector_list_id
950
+ @_next_selector_list_id
951
+ end
952
+
953
+ def next_media_query_list_id
954
+ @_next_media_query_list_id
955
+ end
956
+
957
+ # @return [Hash{Symbol => Array<Integer>}] the raw cached media index,
958
+ # without triggering the public #media_index reader's lazy rebuild
959
+ def media_index_cache
960
+ @media_index
961
+ end
962
+
963
+ def has_nesting
964
+ @_has_nesting
965
+ end
966
+
967
+ def selector_lists
968
+ @_selector_lists
969
+ end
970
+
971
+ def media_query_lists
972
+ @_media_query_lists
973
+ end
974
+
975
+ attr_writer :_hash
976
+
977
+ private
978
+
979
+ # Offset and append MediaQuery objects into @media_queries, without
980
+ # mutating the source objects (a caller may still hold its own
981
+ # references to them, e.g. concat's `other`). Returns the id offset
982
+ # applied, so callers can rebase any rule/import media_query_id that
983
+ # pointed into the source collection.
984
+ #
985
+ # @param source_media_queries [Array<MediaQuery>, nil]
986
+ # @return [Integer] id offset applied to each merged MediaQuery
987
+ def merge_media_queries!(source_media_queries)
988
+ mq_id_offset = @_next_media_query_id
989
+ return mq_id_offset if source_media_queries.nil? || source_media_queries.empty?
990
+
991
+ source_media_queries.each do |mq|
992
+ new_mq = mq.dup
993
+ new_mq.id += mq_id_offset
994
+ @media_queries << new_mq
995
+ end
996
+ @_next_media_query_id += source_media_queries.size
997
+
998
+ mq_id_offset
999
+ end
1000
+
1001
+ # Offset and merge a selector_lists hash (list_id => [rule_ids]) into
1002
+ # @_selector_lists. List ids are always rebased onto the next available
1003
+ # id; rule ids are rebased by rule_id_offset (0 when the caller defers
1004
+ # rule-id fixups to a later bulk renumber).
1005
+ #
1006
+ # @param source_lists [Hash{Integer => Array<Integer>}, nil]
1007
+ # @param rule_id_offset [Integer]
1008
+ # @return [Integer] id offset applied to the source's list ids
1009
+ def merge_selector_lists!(source_lists, rule_id_offset: 0)
1010
+ list_id_offset = @_next_selector_list_id
1011
+ return list_id_offset if source_lists.nil? || source_lists.empty?
1012
+
1013
+ source_lists.each do |list_id, rule_ids|
1014
+ @_selector_lists[list_id + list_id_offset] = rule_ids.map { |id| id + rule_id_offset }
1015
+ end
1016
+ @_next_selector_list_id = list_id_offset + source_lists.size
1017
+
1018
+ list_id_offset
1019
+ end
1020
+
1021
+ # Same shape as merge_selector_lists!, for @_media_query_lists
1022
+ # (list_id => [media_query_ids]).
1023
+ #
1024
+ # @param source_lists [Hash{Integer => Array<Integer>}, nil]
1025
+ # @param mq_id_offset [Integer]
1026
+ # @return [Integer] id offset applied to the source's list ids
1027
+ def merge_media_query_lists!(source_lists, mq_id_offset: 0)
1028
+ list_id_offset = @_next_media_query_list_id
1029
+ return list_id_offset if source_lists.nil? || source_lists.empty?
1030
+
1031
+ source_lists.each do |list_id, mq_ids|
1032
+ @_media_query_lists[list_id + list_id_offset] = mq_ids.map { |id| id + mq_id_offset }
1033
+ end
1034
+ @_next_media_query_list_id = list_id_offset + source_lists.size
1035
+
1036
+ list_id_offset
1037
+ end
1038
+
1039
+ # Merge a media_index hash (media_sym => [rule_ids]) into @media_index,
1040
+ # offsetting the rule ids.
1041
+ #
1042
+ # @param source_index [Hash{Symbol => Array<Integer>}, nil]
1043
+ # @param rule_id_offset [Integer]
1044
+ # @return [void]
1045
+ def merge_media_index!(source_index, rule_id_offset:)
1046
+ return if source_index.nil? || source_index.empty?
1047
+
1048
+ source_index.each do |media_sym, rule_ids|
1049
+ offsetted_ids = rule_ids.map { |id| id + rule_id_offset }
1050
+ if @media_index[media_sym]
1051
+ @media_index[media_sym].concat(offsetted_ids)
1052
+ else
1053
+ @media_index[media_sym] = offsetted_ids
1054
+ end
1055
+ end
1056
+ end
1057
+
1058
+ # Rebase a rule/at-rule's own id and its cross-references (Rule's
1059
+ # selector_list_id, and media_query_id on both Rule and AtRule) by the
1060
+ # given offsets, mutating it in place. Callers merging from a live
1061
+ # Stylesheet they don't own (e.g. concat) must dup the rule first.
1062
+ #
1063
+ # @param rule [Rule, AtRule]
1064
+ # @param rule_id_offset [Integer]
1065
+ # @param list_id_offset [Integer]
1066
+ # @param mq_id_offset [Integer]
1067
+ # @return [void]
1068
+ def rebase_rule!(rule, rule_id_offset:, list_id_offset: 0, mq_id_offset: 0)
1069
+ rule.id += rule_id_offset
1070
+ rule.media_query_id += mq_id_offset if rule.media_query_id
1071
+ return unless rule.is_a?(Rule)
1071
1072
 
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
1073
+ rule.selector_list_id += list_id_offset if rule.selector_list_id
1074
+ end
1075
+
1076
+ # Build parser options for a block, enabling relative -> absolute URL
1077
+ # conversion when both absolute_paths and a base_uri are in effect.
1078
+ #
1079
+ # @return [Hash] Parser options
1080
+ def build_parse_options(effective_base_uri, effective_absolute_paths)
1081
+ parse_options = @parser_options.dup
1082
+ if effective_absolute_paths && effective_base_uri
1083
+ parse_options[:base_uri] = effective_base_uri
1084
+ parse_options[:absolute_paths] = true
1085
+ parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
1076
1086
  end
1087
+ parse_options
1088
+ end
1089
+
1090
+ # Merge a freshly parsed CSS block's result into this stylesheet's
1091
+ # rules, media queries, selector/media-query lists, and index, then
1092
+ # resolve any @import statements it contained.
1093
+ #
1094
+ # @param result [Hash] Return value of the backend's parse
1095
+ # @return [void]
1096
+ def merge_parsed_block!(result, effective_base_uri, effective_base_dir)
1097
+ offset = @_last_rule_id || 0
1098
+
1099
+ list_id_offset = merge_selector_lists!(result[:_selector_lists], rule_id_offset: offset)
1100
+ mq_id_offset = merge_media_queries!(result[:media_queries])
1101
+ merge_media_query_lists!(result[:_media_query_lists], mq_id_offset: mq_id_offset)
1102
+
1103
+ new_rules = result[:rules]
1104
+ new_rules.each do |rule|
1105
+ rebase_rule!(rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
1106
+ @rules << rule
1107
+ end
1108
+ @_last_rule_id = offset + new_rules.length
1109
+
1110
+ merge_media_index!(result[:_media_index], rule_id_offset: offset)
1111
+ merge_block_imports!(result[:imports], offset, mq_id_offset, effective_base_uri, effective_base_dir)
1112
+
1113
+ @charset ||= result[:charset]
1114
+ @_has_nesting = result[:_has_nesting]
1115
+
1116
+ clear_memoized_caches
1117
+ end
1118
+
1119
+ # Merge a freshly parsed block's @import statements into @imports (with
1120
+ # offsetted IDs), then kick off import resolution if enabled.
1121
+ #
1122
+ # @return [void]
1123
+ def merge_block_imports!(new_imports, offset, mq_id_offset, effective_base_uri, effective_base_dir)
1124
+ return unless new_imports
1125
+
1126
+ new_imports.each do |import|
1127
+ import.id += offset
1128
+ import.media_query_id += mq_id_offset if import.media_query_id
1129
+ @imports << import
1130
+ end
1131
+
1132
+ return unless @options[:import]
1133
+
1134
+ if @options[:import].is_a?(Hash)
1135
+ imported_urls = @options[:import][:imported_urls] || []
1136
+ depth = @options[:import][:depth] || 0
1137
+ else
1138
+ imported_urls = []
1139
+ depth = 0
1140
+ end
1141
+
1142
+ import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
1143
+ import_opts[:base_uri] = effective_base_uri if effective_base_uri
1144
+ import_opts[:base_path] = effective_base_dir if effective_base_dir
1145
+
1146
+ resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
1147
+ end
1148
+
1149
+ # Remove MediaQuery objects no longer referenced by any rule, renumbering
1150
+ # the ones that remain so mq.id keeps matching its position in
1151
+ # @media_queries. Lookups elsewhere (parser, serializer) treat
1152
+ # @media_queries[id] as positional, so simply compacting the array
1153
+ # without renumbering would desync mq.id from rule.media_query_id and
1154
+ # @_media_query_lists as soon as an earlier entry gets removed.
1155
+ #
1156
+ # Shared by #remove_rules! (via self) and #- (via result.send, since it
1157
+ # operates on a separate Stylesheet instance).
1158
+ #
1159
+ # @return [void]
1160
+ def compact_media_queries!
1161
+ # Rule and AtRule both define media_query_id directly (it's a member of
1162
+ # both structs), so every element of @rules responds to it - no need
1163
+ # for a respond_to?/is_a? guard here. filter_map keeps only rules that
1164
+ # actually have one set (i.e. are scoped to a media query).
1165
+ used_mq_ids = @rules.filter_map(&:media_query_id).to_set
1077
1166
 
1078
- # Build old_id => new_id mapping
1079
- # Keep MediaQuery objects that are used, maintaining their IDs
1080
1167
  old_to_new_mq_id = {}
1081
1168
  kept_mqs = []
1082
- result.instance_variable_get(:@media_queries).each do |mq|
1169
+ @media_queries.each do |mq|
1083
1170
  next unless used_mq_ids.include?(mq.id)
1084
1171
 
1085
1172
  old_to_new_mq_id[mq.id] = kept_mqs.size
1086
1173
  mq.id = kept_mqs.size
1087
1174
  kept_mqs << mq
1088
1175
  end
1176
+ @media_queries = kept_mqs
1089
1177
 
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
1178
+ @rules.each do |rule|
1179
+ rule.media_query_id = old_to_new_mq_id[rule.media_query_id] if rule.media_query_id
1098
1180
  end
1099
1181
 
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
1182
+ @_media_query_lists.each_value { |mq_ids| mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact! }
1183
+ @_media_query_lists.delete_if { |_list_id, mq_ids| mq_ids.empty? }
1184
+ end
1104
1185
 
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? }
1186
+ # Filter rules down to those relevant for the given media type(s).
1187
+ #
1188
+ # Shared by #to_s and #to_formatted_s so both serialize the same rule set
1189
+ # for a given filter. Base rules (not inside any @media block) always
1190
+ # apply regardless of media context, so they're included alongside any
1191
+ # explicitly requested media types.
1192
+ #
1193
+ # @param media [Symbol, Array<Symbol>] Media type(s) to filter to
1194
+ # @return [Array<Rule>] Filtered rules, in original order
1195
+ def filter_rules_by_media(media)
1196
+ which_media_array = media.is_a?(Array) ? media : [media]
1197
+ return @rules if which_media_array.include?(:all)
1107
1198
 
1108
- # Clear memoized cache
1109
- result.instance_variable_set(:@selectors, nil)
1110
- result.instance_variable_set(:@_hash, nil)
1199
+ mi = media_index # Build media_index if needed
1111
1200
 
1112
- result
1113
- end
1201
+ # Base rules (not in any media query) apply to all media contexts.
1202
+ # Built as a Set so the membership check below is O(1) per rule instead
1203
+ # of the O(n) scan an Array#- would do, and to avoid materializing a
1204
+ # separate 0..N array of every rule id just to subtract from it.
1205
+ media_rule_id_set = Set.new
1206
+ mi.each_value { |ids| media_rule_id_set.merge(ids) }
1207
+ matching_rule_ids = (0...@rules.length).reject { |rule_id| media_rule_id_set.include?(rule_id) }
1114
1208
 
1115
- private
1209
+ # Include rules from requested media types
1210
+ which_media_array.each do |media_sym|
1211
+ matching_rule_ids.concat(mi[media_sym]) if mi[media_sym]
1212
+ end
1213
+ matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
1116
1214
 
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
1215
+ matching_rule_ids.sort!.map! { |rule_id| @rules[rule_id] }
1216
+ end
1122
1217
 
1123
1218
  # Resolve @import statements by fetching and merging imported stylesheets
1124
1219
  #
@@ -1142,146 +1237,151 @@ module Cataract
1142
1237
  imports.each do |import|
1143
1238
  next if import.resolved # Skip already resolved imports
1144
1239
 
1145
- url = import.url
1146
- import_media_query_id = import.media_query_id
1240
+ resolve_single_import!(import, opts, fetcher, imported_urls, depth)
1241
+ end
1147
1242
 
1148
- # Validate URL
1149
- ImportResolver.validate_url(url, opts)
1243
+ # Renumber all rule IDs to be sequential in document order.
1244
+ # This is O(n) and very fast (~1ms for 30k rules). Only needed if we
1245
+ # actually resolved imports.
1246
+ renumber_after_import_resolution! if imports.length > 0
1247
+ end
1150
1248
 
1151
- # Check for circular references
1152
- raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
1249
+ # Fetch, parse, and merge one @import statement's target stylesheet into
1250
+ # this one, inserted at the import's original document position.
1251
+ #
1252
+ # @return [void]
1253
+ def resolve_single_import!(import, opts, fetcher, imported_urls, depth)
1254
+ url = import.url
1255
+ import_media_query_id = import.media_query_id
1153
1256
 
1154
- # Fetch imported CSS
1155
- imported_css = fetcher.call(url, opts)
1257
+ # Validate URL
1258
+ ImportResolver.validate_url(url, opts)
1156
1259
 
1157
- # Parse imported CSS recursively
1158
- imported_urls_copy = imported_urls.dup
1159
- imported_urls_copy << url
1260
+ # Check for circular references
1261
+ raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
1160
1262
 
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
1263
+ # Fetch imported CSS
1264
+ imported_css = fetcher.call(url, opts)
1164
1265
 
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
- }
1266
+ # Parse imported CSS recursively
1267
+ imported_urls_copy = imported_urls.dup
1268
+ imported_urls_copy << url
1170
1269
 
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
1270
+ # Determine the base URI for the imported file
1271
+ # This becomes the new base for resolving relative URLs in the imported CSS
1272
+ imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
1177
1273
 
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
1274
+ # Build parse options for imported CSS
1275
+ parse_opts = {
1276
+ import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1277
+ parser: @parser_options.dup, # Inherit parent's parser options (including selector_lists)
1278
+ backend: @backend # Imported stylesheets are produced by the same backend as their parent
1279
+ }
1184
1280
 
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
1281
+ # If URL conversion is enabled (base_uri present), enable it for imported files too
1282
+ if opts[:base_uri]
1283
+ parse_opts[:absolute_paths] = true
1284
+ parse_opts[:base_uri] = imported_base_uri
1285
+ parse_opts[:uri_resolver] = opts[:uri_resolver]
1286
+ end
1225
1287
 
1226
- # Merge imported rules into this stylesheet
1227
- # Insert at current position (before any remaining local rules)
1228
- insert_position = import.id
1288
+ # Pass parent import's media query context to parser so nested imports can combine
1289
+ if import_media_query_id
1290
+ parent_mq = @media_queries[import_media_query_id]
1291
+ parse_opts[:parser][:parent_import_media_type] = parent_mq.type
1292
+ parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
1293
+ end
1229
1294
 
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
1295
+ imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1234
1296
 
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
1297
+ merge_imported_sheet!(imported_sheet, import)
1243
1298
 
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
1299
+ # Merge charset (first one wins per CSS spec)
1300
+ @charset ||= imported_sheet.charset
1254
1301
 
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
1302
+ # Mark as resolved
1303
+ import.resolved = true
1304
+ end
1265
1305
 
1266
- # Merge charset (first one wins per CSS spec)
1267
- @charset ||= imported_sheet.instance_variable_get(:@charset)
1306
+ # Merge an already-parsed imported stylesheet's media queries, rules,
1307
+ # and selector/media-query lists into this one. Rules are inserted at
1308
+ # the importing @import statement's original document position; rule
1309
+ # ids (and selector_lists' rule-id references) are left unoffset here
1310
+ # and fixed up in one bulk pass once all imports are resolved, since
1311
+ # positional insertion shifts everything after it anyway.
1312
+ #
1313
+ # @return [void]
1314
+ def merge_imported_sheet!(imported_sheet, import)
1315
+ mq_id_offset = merge_media_queries!(imported_sheet.media_queries)
1316
+ rebase_imported_rules_media_query!(imported_sheet.rules, mq_id_offset, import.media_query_id)
1268
1317
 
1269
- # Mark as resolved
1270
- import.resolved = true
1318
+ insert_position = import.id
1319
+ imported_sheet.rules.each_with_index do |rule, idx|
1320
+ @rules.insert(insert_position + idx, rule)
1271
1321
  end
1272
1322
 
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
1323
+ merge_selector_lists!(imported_sheet.selector_lists)
1324
+ merge_media_query_lists!(imported_sheet.media_query_lists, mq_id_offset: mq_id_offset)
1325
+ end
1277
1326
 
1327
+ # Rebase every imported rule/at-rule's media_query_id onto this
1328
+ # stylesheet's own @media_queries (now that the imported sheet's own
1329
+ # media queries have been merged in), then, if the @import statement
1330
+ # itself had a media qualifier, combine it with (or assign it to) each
1331
+ # rule's media context.
1332
+ #
1333
+ # @return [void]
1334
+ def rebase_imported_rules_media_query!(imported_rules, mq_id_offset, import_media_query_id)
1335
+ imported_rules.each { |rule| rebase_rule!(rule, rule_id_offset: 0, mq_id_offset: mq_id_offset) }
1336
+
1337
+ return unless import_media_query_id
1338
+
1339
+ import_mq = @media_queries[import_media_query_id]
1340
+
1341
+ imported_rules.each do |rule|
1342
+ next unless rule.is_a?(Rule)
1343
+
1344
+ if rule.media_query_id
1345
+ # Rule already has a media query - need to combine them
1346
+ # Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
1347
+ # Result: screen and (max-width: 768px)
1348
+ existing_mq = @media_queries[rule.media_query_id]
1349
+
1350
+ # The type is always the import's type (leftmost)
1351
+ combined_type = import_mq.type
1352
+ combined_conditions = if import_mq.conditions && existing_mq.conditions
1353
+ "#{import_mq.conditions} and #{existing_mq.conditions}"
1354
+ elsif import_mq.conditions
1355
+ "#{import_mq.conditions} and #{existing_mq.text}"
1356
+ elsif existing_mq.conditions
1357
+ existing_mq.conditions
1358
+ else
1359
+ existing_mq.text
1360
+ end
1361
+
1362
+ combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
1363
+ @media_queries << combined_mq
1364
+ rule.media_query_id = @_next_media_query_id
1365
+ @_next_media_query_id += 1
1366
+ else
1367
+ # Rule has no media query - just assign the import's media query
1368
+ rule.media_query_id = import_media_query_id
1369
+ end
1370
+ end
1371
+ end
1372
+
1373
+ # After all imports for this call are resolved, renumber every rule
1374
+ # (including at-rules) and import statement placeholder to sequential
1375
+ # ids matching final document order, and propagate the same mapping to
1376
+ # selector_lists' rule-id references.
1377
+ #
1378
+ # @return [void]
1379
+ def renumber_after_import_resolution!
1278
1380
  # Single-pass renumbering: build old->new mapping while renumbering
1279
1381
  old_to_new_id = {}
1280
1382
  @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
1383
+ old_to_new_id[rule.id] = new_idx
1384
+ rule.id = new_idx
1285
1385
  end
1286
1386
 
1287
1387
  # Update rule IDs in selector_lists (only if we have any)
@@ -1298,32 +1398,6 @@ module Cataract
1298
1398
  @media_index = {}
1299
1399
  end
1300
1400
 
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
1401
  # Clear memoized caches that can be lazily rebuilt.
1328
1402
  #
1329
1403
  # Call this method after any operation that modifies the stylesheet's rules
@@ -1372,19 +1446,5 @@ module Cataract
1372
1446
 
1373
1447
  props_by_media
1374
1448
  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
1449
  end
1390
1450
  end