cataract 0.1.3 → 0.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +44 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +96 -4
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +62 -141
  7. data/CHANGELOG.md +20 -0
  8. data/RAGEL_MIGRATION.md +2 -2
  9. data/README.md +37 -4
  10. data/Rakefile +72 -32
  11. data/cataract.gemspec +4 -1
  12. data/ext/cataract/cataract.c +59 -50
  13. data/ext/cataract/cataract.h +5 -3
  14. data/ext/cataract/css_parser.c +173 -65
  15. data/ext/cataract/extconf.rb +2 -2
  16. data/ext/cataract/{merge.c → flatten.c} +526 -468
  17. data/ext/cataract/shorthand_expander.c +164 -115
  18. data/lib/cataract/at_rule.rb +8 -9
  19. data/lib/cataract/declaration.rb +18 -0
  20. data/lib/cataract/import_resolver.rb +63 -43
  21. data/lib/cataract/import_statement.rb +49 -0
  22. data/lib/cataract/pure/byte_constants.rb +69 -0
  23. data/lib/cataract/pure/flatten.rb +1145 -0
  24. data/lib/cataract/pure/helpers.rb +35 -0
  25. data/lib/cataract/pure/imports.rb +268 -0
  26. data/lib/cataract/pure/parser.rb +1340 -0
  27. data/lib/cataract/pure/serializer.rb +590 -0
  28. data/lib/cataract/pure/specificity.rb +206 -0
  29. data/lib/cataract/pure.rb +153 -0
  30. data/lib/cataract/rule.rb +69 -15
  31. data/lib/cataract/stylesheet.rb +356 -49
  32. data/lib/cataract/version.rb +1 -1
  33. data/lib/cataract.rb +43 -26
  34. metadata +14 -26
  35. data/benchmarks/benchmark_harness.rb +0 -193
  36. data/benchmarks/benchmark_merging.rb +0 -121
  37. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  38. data/benchmarks/benchmark_parsing.rb +0 -153
  39. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  40. data/benchmarks/benchmark_runner.rb +0 -70
  41. data/benchmarks/benchmark_serialization.rb +0 -180
  42. data/benchmarks/benchmark_shorthand.rb +0 -109
  43. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  44. data/benchmarks/benchmark_specificity.rb +0 -124
  45. data/benchmarks/benchmark_string_allocation.rb +0 -151
  46. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  47. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  48. data/benchmarks/benchmark_value_splitter.rb +0 -54
  49. data/benchmarks/benchmark_yjit.rb +0 -158
  50. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  51. data/benchmarks/profile_to_s.rb +0 -23
  52. data/benchmarks/speedup_calculator.rb +0 -83
  53. data/benchmarks/system_metadata.rb +0 -81
  54. data/benchmarks/templates/benchmarks.md.erb +0 -221
  55. data/benchmarks/yjit_tests.rb +0 -141
  56. data/scripts/fuzzer/run.rb +0 -828
  57. data/scripts/fuzzer/worker.rb +0 -99
  58. data/scripts/generate_benchmarks_md.rb +0 -155
@@ -19,6 +19,7 @@ module Cataract
19
19
  #
20
20
  # @attr_reader [Array<Rule>] rules Array of parsed CSS rules
21
21
  # @attr_reader [String, nil] charset The @charset declaration if present
22
+ # @attr_reader [Array<ImportStatement>] imports Array of @import statements
22
23
  class Stylesheet
23
24
  include Enumerable
24
25
 
@@ -28,6 +29,9 @@ module Cataract
28
29
  # @return [String, nil] The @charset declaration if present
29
30
  attr_reader :charset
30
31
 
32
+ # @return [Array<ImportStatement>] Array of @import statements
33
+ attr_reader :imports
34
+
31
35
  # Create a new empty stylesheet.
32
36
  #
33
37
  # @param options [Hash] Configuration options
@@ -48,6 +52,25 @@ module Cataract
48
52
  @rules = [] # Flat array of Rule structs
49
53
  @_media_index = {} # Hash: Symbol => Array of rule IDs
50
54
  @charset = nil
55
+ @imports = [] # Array of ImportStatement objects
56
+ @_has_nesting = nil # Set by parser (nil or boolean)
57
+ @_last_rule_id = nil # Tracks next rule ID for add_block
58
+ @selectors = nil # Memoized cache of selectors
59
+ end
60
+
61
+ # Initialize copy for proper deep duplication.
62
+ #
63
+ # Ensures that dup/clone creates a proper deep copy of the stylesheet,
64
+ # duplicating internal arrays and hashes so mutations don't affect the original.
65
+ #
66
+ # @param source [Stylesheet] Source stylesheet being copied
67
+ def initialize_copy(source)
68
+ super
69
+ @rules = source.instance_variable_get(:@rules).dup
70
+ @imports = source.instance_variable_get(:@imports).dup
71
+ @_media_index = source.instance_variable_get(:@_media_index).transform_values(&:dup)
72
+ @selectors = nil # Clear memoized cache
73
+ @_hash = nil # Clear cached hash
51
74
  end
52
75
 
53
76
  # Parse CSS and return a new Stylesheet
@@ -104,6 +127,12 @@ module Cataract
104
127
  @rules.each(&)
105
128
  end
106
129
 
130
+ def [](offset)
131
+ return unless @rules
132
+
133
+ @rules[offset]
134
+ end
135
+
107
136
  # Filter rules by media query symbol(s).
108
137
  #
109
138
  # Returns a chainable StylesheetScope that can be further filtered.
@@ -297,12 +326,13 @@ module Cataract
297
326
  Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false)
298
327
  else
299
328
  # Collect all rule IDs that match the requested media types
300
- matching_rule_ids = Set.new
329
+ matching_rule_ids = []
301
330
  which_media_array.each do |media_sym|
302
331
  if @_media_index[media_sym]
303
- matching_rule_ids.merge(@_media_index[media_sym])
332
+ matching_rule_ids.concat(@_media_index[media_sym])
304
333
  end
305
334
  end
335
+ matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
306
336
 
307
337
  # Build filtered rules array (keep original IDs, no recreation needed)
308
338
  filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
@@ -311,7 +341,7 @@ module Cataract
311
341
  filtered_media_index = {}
312
342
  which_media_array.each do |media_sym|
313
343
  if @_media_index[media_sym]
314
- filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
344
+ filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids
315
345
  end
316
346
  end
317
347
 
@@ -353,20 +383,21 @@ module Cataract
353
383
  Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false)
354
384
  else
355
385
  # Collect all rule IDs that match the requested media types
356
- matching_rule_ids = Set.new
386
+ matching_rule_ids = []
357
387
 
358
388
  # Include rules not in any media query (they apply to all media)
359
389
  media_rule_ids = @_media_index.values.flatten.uniq
360
390
  all_rule_ids = (0...@rules.length).to_a
361
391
  non_media_rule_ids = all_rule_ids - media_rule_ids
362
- matching_rule_ids.merge(non_media_rule_ids)
392
+ matching_rule_ids.concat(non_media_rule_ids)
363
393
 
364
394
  # Include rules from requested media types
365
395
  which_media_array.each do |media_sym|
366
396
  if @_media_index[media_sym]
367
- matching_rule_ids.merge(@_media_index[media_sym])
397
+ matching_rule_ids.concat(@_media_index[media_sym])
368
398
  end
369
399
  end
400
+ matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
370
401
 
371
402
  # Build filtered rules array (keep original IDs, no recreation needed)
372
403
  filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
@@ -375,7 +406,7 @@ module Cataract
375
406
  filtered_media_index = {}
376
407
  which_media_array.each do |media_sym|
377
408
  if @_media_index[media_sym]
378
- filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
409
+ filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids
379
410
  end
380
411
  end
381
412
 
@@ -480,37 +511,66 @@ module Cataract
480
511
  self
481
512
  end
482
513
 
483
- # Remove rules matching criteria
514
+ # Remove rules from the stylesheet
484
515
  #
485
- # @param selector [String, nil] Selector to match (nil matches all)
486
- # @param media_types [Symbol, Array<Symbol>, nil] Media types to filter by (nil matches all)
516
+ # @param rules_or_css [String, Rule, AtRule, Array<Rule, AtRule>] Rules to remove.
517
+ # Can be a CSS string to parse (selectors will be matched), a single Rule/AtRule object,
518
+ # or an array of Rule/AtRule objects.
519
+ # @param media_types [Symbol, Array<Symbol>, nil] Optional media types to filter removal.
520
+ # Only removes rules that match these media types. Pass :all to include base rules.
487
521
  # @return [self] Returns self for method chaining
488
522
  #
489
- # @example Remove all rules with a specific selector
490
- # sheet.remove_rules!(selector: '.header')
523
+ # @example Remove rules by CSS string
524
+ # sheet.remove_rules!('.header { }')
525
+ # sheet.remove_rules!('.header { } .footer { }')
491
526
  #
492
527
  # @example Remove rules from specific media type
493
- # sheet.remove_rules!(selector: '.header', media_types: :screen)
494
- #
495
- # @example Remove all rules from a media type
496
- # sheet.remove_rules!(media_types: :print)
497
- def remove_rules!(selector: nil, media_types: nil)
528
+ # sheet.remove_rules!('.header { }', media_types: :screen)
529
+ #
530
+ # @example Remove specific rule objects
531
+ # rules = sheet.select { |r| r.selector =~ /\.btn-/ }
532
+ # sheet.remove_rules!(rules)
533
+ #
534
+ # @example Remove rules with media filtering
535
+ # sheet.remove_rules!(sheet.with_selector('.header'), media_types: :print)
536
+ def remove_rules!(rules_or_css, media_types: nil)
537
+ # Determine if we're matching by selector (CSS string) or by object identity (rule objects)
538
+ if rules_or_css.is_a?(String)
539
+ # Parse CSS string and extract selectors for matching
540
+ parsed = Stylesheet.parse(rules_or_css)
541
+ selectors_to_remove = parsed.rules.filter_map(&:selector).to_set
542
+ match_by_selector = true
543
+ else
544
+ # Use rule objects directly
545
+ rules_to_remove = rules_or_css.is_a?(Array) ? rules_or_css : [rules_or_css]
546
+ return self if rules_to_remove.empty?
547
+
548
+ match_by_selector = false
549
+ end
550
+
498
551
  # Normalize media_types to array
499
552
  filter_media = media_types ? Array(media_types).map(&:to_sym) : nil
500
553
 
501
- # Find rules to remove
502
- rules_to_remove = Set.new
554
+ # Find rule IDs to remove
555
+ rule_ids_to_remove = []
503
556
  @rules.each_with_index do |rule, rule_id|
504
- # Check selector match
505
- next if selector && rule.selector != selector
506
-
507
- # Check media type match
557
+ # Check if this rule matches
558
+ matches = if match_by_selector
559
+ # Match by selector for CSS string input
560
+ selectors_to_remove.include?(rule.selector)
561
+ else
562
+ # Match by object equality for rule collection input
563
+ rules_to_remove.any?(rule)
564
+ end
565
+ next unless matches
566
+
567
+ # Check media type match if filter is specified
508
568
  if filter_media
509
569
  rule_media_types = @_media_index.select { |_media, ids| ids.include?(rule_id) }.keys
510
570
  # Extract individual media types from complex queries
511
571
  individual_types = rule_media_types.flat_map { |key| Cataract.parse_media_types(key) }.uniq
512
572
 
513
- # If rule is not in any media query (base rule), skip if filtering by media
573
+ # If rule is not in any media query (base rule), skip unless :all is specified
514
574
  if individual_types.empty?
515
575
  next unless filter_media.include?(:all)
516
576
  else
@@ -519,11 +579,11 @@ module Cataract
519
579
  end
520
580
  end
521
581
 
522
- rules_to_remove << rule_id
582
+ rule_ids_to_remove << rule_id
523
583
  end
524
584
 
525
- # Remove rules and update media_index
526
- rules_to_remove.sort.reverse_each do |rule_id|
585
+ # Remove rules and update media_index (sort in reverse to maintain indices during deletion)
586
+ rule_ids_to_remove.sort.reverse_each do |rule_id|
527
587
  @rules.delete_at(rule_id)
528
588
 
529
589
  # Remove from media_index and update IDs for rules after this one
@@ -552,7 +612,6 @@ module Cataract
552
612
  # @param fix_braces [Boolean] Automatically close missing braces
553
613
  # @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
554
614
  # @return [self] Returns self for method chaining
555
- # TODO: Move to C?
556
615
  def add_block(css, fix_braces: false, media_types: nil)
557
616
  css += ' }' if fix_braces && !css.strip.end_with?('}')
558
617
 
@@ -562,18 +621,11 @@ module Cataract
562
621
  css = "@media #{media_list} { #{css} }"
563
622
  end
564
623
 
565
- # Resolve @import statements if configured in constructor
566
- css_to_parse = if @options[:import]
567
- ImportResolver.resolve(css, @options[:import])
568
- else
569
- css
570
- end
571
-
572
624
  # Get current rule ID offset
573
625
  offset = @_last_rule_id || 0
574
626
 
575
- # Parse CSS with C function (returns hash)
576
- result = Cataract._parse_css(css_to_parse)
627
+ # Parse CSS first (this extracts @import statements into result[:imports])
628
+ result = Cataract._parse_css(css)
577
629
 
578
630
  # Merge rules with offsetted IDs
579
631
  new_rules = result[:rules]
@@ -595,6 +647,28 @@ module Cataract
595
647
  # Update last rule ID
596
648
  @_last_rule_id = offset + new_rules.length
597
649
 
650
+ # Merge imports with offsetted IDs
651
+ if result[:imports]
652
+ new_imports = result[:imports]
653
+ new_imports.each do |import|
654
+ import.id += offset
655
+ @imports << import
656
+ end
657
+
658
+ # Resolve imports if configured
659
+ if @options[:import]
660
+ # Extract imported_urls and depth from options
661
+ if @options[:import].is_a?(Hash)
662
+ imported_urls = @options[:import][:imported_urls] || []
663
+ depth = @options[:import][:depth] || 0
664
+ else
665
+ imported_urls = []
666
+ depth = 0
667
+ end
668
+ resolve_imports(new_imports, @options[:import], imported_urls: imported_urls, depth: depth)
669
+ end
670
+ end
671
+
598
672
  # Set charset if not already set
599
673
  @charset ||= result[:charset]
600
674
 
@@ -637,33 +711,171 @@ module Cataract
637
711
  end
638
712
  end
639
713
 
640
- # Merge all rules in this stylesheet according to CSS cascade rules
714
+ # Compare stylesheets for equality.
715
+ #
716
+ # Two stylesheets are equal if they have the same rules in the same order
717
+ # and the same media queries. Rule equality uses shorthand-aware comparison.
718
+ # Order matters because CSS cascade depends on rule order.
719
+ #
720
+ # Charset is ignored since it's file encoding metadata, not semantic content.
721
+ #
722
+ # @param other [Object] Object to compare with
723
+ # @return [Boolean] true if stylesheets are equal
724
+ def ==(other)
725
+ return false unless other.is_a?(Stylesheet)
726
+ return false unless rules == other.rules
727
+ return false unless @_media_index == other.instance_variable_get(:@_media_index)
728
+
729
+ true
730
+ end
731
+ alias eql? ==
732
+
733
+ # Generate hash code for this stylesheet.
734
+ #
735
+ # Hash is based on rules and media_index to match equality semantics.
736
+ #
737
+ # @return [Integer] hash code
738
+ def hash
739
+ @_hash ||= [self.class, rules, @_media_index].hash # rubocop:disable Naming/MemoizedInstanceVariableName
740
+ end
741
+
742
+ # Flatten all rules in this stylesheet according to CSS cascade rules.
641
743
  #
642
744
  # Applies specificity and !important precedence rules to compute the final
643
745
  # set of declarations. Also recreates shorthand properties from longhand
644
746
  # properties where possible.
645
747
  #
646
- # @return [Stylesheet] New stylesheet with a single merged rule
748
+ # @return [Stylesheet] New stylesheet with cascade applied
749
+ def flatten
750
+ Cataract.flatten(self)
751
+ end
752
+ alias cascade flatten
753
+
754
+ # Deprecated: Use flatten instead
647
755
  def merge
648
- # C function handles everything - returns new Stylesheet
649
- Cataract.merge(self)
756
+ warn 'Stylesheet#merge is deprecated, use #flatten instead', uplevel: 1
757
+ flatten
650
758
  end
651
759
 
652
- # Merge rules in-place, mutating the receiver.
760
+ # Flatten rules in-place, mutating the receiver.
653
761
  #
654
762
  # This is a convenience method that updates the stylesheet's internal
655
- # rules and media_index with the merged result. The Stylesheet object
656
- # itself is mutated (same object_id), but note that the C merge function
763
+ # rules and media_index with the flattened result. The Stylesheet object
764
+ # itself is mutated (same object_id), but note that the C flatten function
657
765
  # still allocates new arrays internally.
658
766
  #
659
767
  # @return [self] Returns self for method chaining
660
- def merge!
661
- merged = Cataract.merge(self)
662
- @rules = merged.instance_variable_get(:@rules)
663
- @_media_index = merged.instance_variable_get(:@_media_index)
664
- @_has_nesting = merged.instance_variable_get(:@_has_nesting)
768
+ def flatten!
769
+ flattened = Cataract.flatten(self)
770
+ @rules = flattened.instance_variable_get(:@rules)
771
+ @_media_index = flattened.instance_variable_get(:@_media_index)
772
+ @_has_nesting = flattened.instance_variable_get(:@_has_nesting)
665
773
  self
666
774
  end
775
+ alias cascade! flatten!
776
+
777
+ # Deprecated: Use flatten! instead
778
+ def merge!
779
+ warn 'Stylesheet#merge! is deprecated, use #flatten! instead', uplevel: 1
780
+ flatten!
781
+ end
782
+
783
+ # Concatenate another stylesheet's rules into this one and apply cascade.
784
+ #
785
+ # Adds all rules from the other stylesheet to this one, then applies
786
+ # CSS cascade to resolve conflicts. Media queries are merged.
787
+ #
788
+ # @param other [Stylesheet] Stylesheet to concatenate
789
+ # @return [self] Returns self for method chaining
790
+ def concat(other)
791
+ raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
792
+
793
+ # Get the current offset for rule IDs
794
+ offset = @rules.length
795
+
796
+ # Add rules with updated IDs
797
+ other.rules.each do |rule|
798
+ new_rule = rule.dup
799
+ new_rule.id = @rules.length
800
+ @rules << new_rule
801
+ end
802
+
803
+ # Merge media_index with offsetted IDs
804
+ other.instance_variable_get(:@_media_index).each do |media_sym, rule_ids|
805
+ offsetted_ids = rule_ids.map { |id| id + offset }
806
+ if @_media_index[media_sym]
807
+ @_media_index[media_sym].concat(offsetted_ids)
808
+ else
809
+ @_media_index[media_sym] = offsetted_ids
810
+ end
811
+ end
812
+
813
+ # Update nesting flag if other has nesting
814
+ other_has_nesting = other.instance_variable_get(:@_has_nesting)
815
+ @_has_nesting = true if other_has_nesting
816
+
817
+ # Clear memoized cache
818
+ @selectors = nil
819
+
820
+ # Apply cascade in-place
821
+ flatten!
822
+ end
823
+
824
+ # Combine two stylesheets into a new one and apply cascade.
825
+ #
826
+ # Creates a new stylesheet containing rules from both stylesheets,
827
+ # then applies CSS cascade to resolve conflicts.
828
+ #
829
+ # @param other [Stylesheet] Stylesheet to combine with
830
+ # @return [Stylesheet] New stylesheet with combined and cascaded rules
831
+ def +(other)
832
+ result = dup
833
+ result.concat(other)
834
+ result
835
+ end
836
+
837
+ # Remove matching rules from this stylesheet.
838
+ #
839
+ # Creates a new stylesheet with rules that don't match any rules in the
840
+ # other stylesheet. Uses Rule#== for matching (shorthand-aware).
841
+ # Does NOT apply cascade to the result.
842
+ #
843
+ # @param other [Stylesheet] Stylesheet containing rules to remove
844
+ # @return [Stylesheet] New stylesheet with matching rules removed
845
+ def -(other)
846
+ raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
847
+
848
+ result = dup
849
+
850
+ # Remove matching rules using Rule#==
851
+ rules_to_remove_ids = []
852
+ result.rules.each_with_index do |rule, idx|
853
+ rules_to_remove_ids << idx if other.rules.include?(rule)
854
+ end
855
+
856
+ # Remove in reverse order to maintain indices
857
+ rules_to_remove_ids.reverse_each do |idx|
858
+ result.rules.delete_at(idx)
859
+
860
+ # Update media_index: remove this rule ID and decrement higher IDs
861
+ result.instance_variable_get(:@_media_index).each_value do |ids|
862
+ ids.delete(idx)
863
+ ids.map! { |id| id > idx ? id - 1 : id }
864
+ end
865
+ end
866
+
867
+ # Re-index remaining rules
868
+ result.rules.each_with_index { |rule, new_id| rule.id = new_id }
869
+
870
+ # Clean up empty media_index entries
871
+ result.instance_variable_get(:@_media_index).delete_if { |_media, ids| ids.empty? }
872
+
873
+ # Clear memoized cache
874
+ result.instance_variable_set(:@selectors, nil)
875
+ result.instance_variable_set(:@_hash, nil)
876
+
877
+ result
878
+ end
667
879
 
668
880
  private
669
881
 
@@ -673,6 +885,101 @@ module Cataract
673
885
  # @return [Hash<Symbol, Array<Integer>>]
674
886
  attr_reader :_media_index
675
887
 
888
+ # Resolve @import statements by fetching and merging imported stylesheets
889
+ #
890
+ # @param imports [Array<ImportStatement>] Import statements to resolve
891
+ # @param options [Hash] Import resolution options
892
+ # @param imported_urls [Array<String>] URLs already imported (for circular detection)
893
+ # @param depth [Integer] Current import depth (for depth limit)
894
+ # @return [void]
895
+ def resolve_imports(imports, options, imported_urls: [], depth: 0)
896
+ # Normalize options with safe defaults
897
+ opts = ImportResolver.normalize_options(options)
898
+
899
+ # Check depth limit
900
+ if depth > opts[:max_depth]
901
+ raise ImportError, "Import nesting too deep: exceeded maximum depth of #{opts[:max_depth]}"
902
+ end
903
+
904
+ # Get or create fetcher
905
+ fetcher = opts[:fetcher] || ImportResolver::DefaultFetcher.new
906
+
907
+ imports.each do |import|
908
+ next if import.resolved # Skip already resolved imports
909
+
910
+ url = import.url
911
+ media = import.media
912
+
913
+ # Validate URL
914
+ ImportResolver.validate_url(url, opts)
915
+
916
+ # Check for circular references
917
+ raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
918
+
919
+ # Fetch imported CSS
920
+ imported_css = fetcher.call(url, opts)
921
+
922
+ # Parse imported CSS recursively
923
+ imported_urls_copy = imported_urls.dup
924
+ imported_urls_copy << url
925
+ imported_sheet = Stylesheet.parse(imported_css, import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1))
926
+
927
+ # Wrap rules in @media if import had media query
928
+ if media
929
+ imported_sheet.rules.each do |rule|
930
+ # Find rule's current media (if any) from imported sheet's media index
931
+ # A rule may be in multiple media entries (e.g., :screen and :"screen and (min-width: 768px)")
932
+ # We want the most specific one (longest string)
933
+ # TODO: Extract this logic to a helper method to keep it consistent across codebase
934
+ rule_media = nil
935
+ imported_sheet.instance_variable_get(:@_media_index).each do |m, ids|
936
+ # Keep the longest/most specific media query
937
+ if ids.include?(rule.id) && (rule_media.nil? || m.to_s.length > rule_media.to_s.length)
938
+ rule_media = m
939
+ end
940
+ end
941
+
942
+ # Combine media queries: "import_media and rule_media"
943
+ combined_media = if rule_media
944
+ # Combine: "media and rule_media"
945
+ :"#{media} and #{rule_media}"
946
+ else
947
+ media
948
+ end
949
+
950
+ # Update media index
951
+ if @_media_index[combined_media]
952
+ @_media_index[combined_media] << rule.id
953
+ else
954
+ @_media_index[combined_media] = [rule.id]
955
+ end
956
+ end
957
+ end
958
+
959
+ # Merge imported rules into this stylesheet
960
+ # Insert at current position (before any remaining local rules)
961
+ insert_position = import.id
962
+ imported_sheet.rules.each_with_index do |rule, idx|
963
+ @rules.insert(insert_position + idx, rule)
964
+ end
965
+
966
+ # Merge media index
967
+ imported_sheet.instance_variable_get(:@_media_index).each do |media_sym, rule_ids|
968
+ if @_media_index[media_sym]
969
+ @_media_index[media_sym].concat(rule_ids)
970
+ else
971
+ @_media_index[media_sym] = rule_ids.dup
972
+ end
973
+ end
974
+
975
+ # Merge charset (first one wins per CSS spec)
976
+ @charset ||= imported_sheet.instance_variable_get(:@charset)
977
+
978
+ # Mark as resolved
979
+ import.resolved = true
980
+ end
981
+ end
982
+
676
983
  # Check if a rule matches any of the requested media queries
677
984
  #
678
985
  # @param rule_id [Integer] Rule ID to check
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cataract
4
- VERSION = '0.1.3'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/cataract.rb CHANGED
@@ -1,9 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cataract/version'
4
- require_relative 'cataract/native_extension'
4
+
5
+ # Load struct definitions first (before C extension or pure Ruby)
6
+ require_relative 'cataract/declaration'
5
7
  require_relative 'cataract/rule'
6
8
  require_relative 'cataract/at_rule'
9
+ require_relative 'cataract/import_statement'
10
+
11
+ # Load pure Ruby or C extension based on ENV var
12
+ if %w[1 true].include?(ENV.fetch('CATARACT_PURE', nil)) || RUBY_ENGINE == 'jruby'
13
+ require_relative 'cataract/pure'
14
+ else
15
+ require_relative 'cataract/native_extension'
16
+ end
17
+
18
+ # Load supporting Ruby files (used by both implementations)
7
19
  require_relative 'cataract/stylesheet_scope'
8
20
  require_relative 'cataract/stylesheet'
9
21
  require_relative 'cataract/declarations'
@@ -23,8 +35,8 @@ require_relative 'cataract/import_resolver'
23
35
  # # Query rules
24
36
  # sheet.select(&:selector?).each { |rule| puts "#{rule.selector}: #{rule.declarations}" }
25
37
  #
26
- # # Merge with cascade rules
27
- # merged = sheet.merge
38
+ # # Flatten with cascade rules
39
+ # flattened = sheet.flatten
28
40
  #
29
41
  # @see Stylesheet Main class for working with parsed CSS
30
42
  # @see Rule Represents individual CSS rules
@@ -60,48 +72,53 @@ module Cataract
60
72
  #
61
73
  # @see Stylesheet#parse
62
74
  # @see Stylesheet.parse
63
- def parse_css(css, imports: false)
64
- # Resolve @import statements if requested
65
- css = ImportResolver.resolve(css, imports) if imports
66
-
67
- Stylesheet.parse(css)
75
+ unless method_defined?(:parse_css)
76
+ def parse_css(css, imports: false)
77
+ # Pass import options to Stylesheet.parse
78
+ # The new flow: parse first (extract @import), then resolve them
79
+ if imports
80
+ Stylesheet.parse(css, import: imports)
81
+ else
82
+ Stylesheet.parse(css)
83
+ end
84
+ end
68
85
  end
69
86
 
70
- # Merge CSS rules according to CSS cascade rules.
87
+ # Flatten CSS rules according to CSS cascade rules.
71
88
  #
72
- # Takes a Stylesheet or CSS string and merges all rules according to CSS cascade
73
- # precedence rules. Returns a new Stylesheet with a single merged rule containing
89
+ # Takes a Stylesheet or CSS string and flattens all rules according to CSS cascade
90
+ # precedence rules. Returns a new Stylesheet with flattened rules containing
74
91
  # the final computed declarations.
75
92
  #
76
- # @param stylesheet_or_css [Stylesheet, String] The stylesheet to merge, or a CSS string to parse and merge
77
- # @return [Stylesheet] A new Stylesheet with merged rules
93
+ # @param stylesheet_or_css [Stylesheet, String] The stylesheet to flatten, or a CSS string to parse and flatten
94
+ # @return [Stylesheet] A new Stylesheet with flattened rules
78
95
  #
79
- # Merge rules (in order of precedence):
96
+ # Flatten rules (in order of precedence):
80
97
  # 1. !important declarations win over non-important
81
98
  # 2. Higher specificity wins
82
99
  # 3. Later declarations with same specificity and importance win
83
100
  # 4. Shorthand properties are created from longhand when possible (e.g., margin-* -> margin)
84
101
  #
85
- # @example Merge a stylesheet
102
+ # @example Flatten a stylesheet
86
103
  # sheet = Cataract.parse_css(".test { color: red; } #test { color: blue; }")
87
- # merged = Cataract.merge(sheet)
88
- # merged.rules.first.declarations #=> [#<Declaration property="color" value="blue" important=false>]
104
+ # flattened = Cataract.flatten(sheet)
105
+ # flattened.rules.first.declarations #=> [#<Declaration property="color" value="blue" important=false>]
89
106
  #
90
- # @example Merge with !important
107
+ # @example Flatten with !important
91
108
  # sheet = Cataract.parse_css(".test { color: red !important; } #test { color: blue; }")
92
- # merged = Cataract.merge(sheet)
93
- # merged.rules.first.declarations #=> [#<Declaration property="color" value="red" important=true>]
109
+ # flattened = Cataract.flatten(sheet)
110
+ # flattened.rules.first.declarations #=> [#<Declaration property="color" value="red" important=true>]
94
111
  #
95
112
  # @example Shorthand creation
96
113
  # css = ".test { margin-top: 10px; margin-right: 10px; margin-bottom: 10px; margin-left: 10px; }"
97
- # merged = Cataract.merge(Cataract.parse_css(css))
98
- # # merged contains single "margin: 10px" declaration instead of four longhand properties
114
+ # flattened = Cataract.flatten(Cataract.parse_css(css))
115
+ # # flattened contains single "margin: 10px" declaration instead of four longhand properties
99
116
  #
100
117
  # @note This is a module-level convenience method. The same functionality is available
101
- # as an instance method: `stylesheet.merge`
102
- # @note Implemented in C (see ext/cataract/merge.c)
118
+ # as an instance method: `stylesheet.flatten`
119
+ # @note Implemented in C (see ext/cataract/flatten.c)
103
120
  #
104
- # @see Stylesheet#merge
105
- # Cataract.merge is defined in C via rb_define_module_function
121
+ # @see Stylesheet#flatten
122
+ # Cataract.flatten is defined in C via rb_define_module_function
106
123
  end
107
124
  end