cataract 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -3
  4. data/.rubocop.yml +1 -0
  5. data/BENCHMARKS.md +24 -6
  6. data/CHANGELOG.md +29 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +251 -458
  11. data/ext/cataract/cataract.h +127 -16
  12. data/ext/cataract/css_parser.c +1029 -700
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +13 -7
  18. data/lib/cataract/error.rb +49 -0
  19. data/lib/cataract/import_resolver.rb +36 -16
  20. data/lib/cataract/pure/byte_constants.rb +4 -1
  21. data/lib/cataract/pure/flatten.rb +143 -145
  22. data/lib/cataract/pure/parser.rb +623 -388
  23. data/lib/cataract/pure/serializer.rb +109 -104
  24. data/lib/cataract/pure/specificity.rb +112 -156
  25. data/lib/cataract/pure.rb +1 -6
  26. data/lib/cataract/stylesheet.rb +480 -410
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +1 -0
  29. metadata +3 -13
  30. data/ext/cataract/import_scanner.c +0 -174
  31. data/ext/cataract_old/cataract.c +0 -393
  32. data/ext/cataract_old/cataract.h +0 -250
  33. data/ext/cataract_old/css_parser.c +0 -933
  34. data/ext/cataract_old/extconf.rb +0 -67
  35. data/ext/cataract_old/import_scanner.c +0 -174
  36. data/ext/cataract_old/merge.c +0 -776
  37. data/ext/cataract_old/shorthand_expander.c +0 -902
  38. data/ext/cataract_old/specificity.c +0 -213
  39. data/ext/cataract_old/stylesheet.c +0 -290
  40. data/ext/cataract_old/value_splitter.c +0 -116
@@ -23,10 +23,6 @@ module Cataract
23
23
  # Maximum media queries (prevent symbol table exhaustion)
24
24
  MAX_MEDIA_QUERIES = 1000
25
25
 
26
- # Maximum property name/value lengths
27
- MAX_PROPERTY_NAME_LENGTH = 256
28
- MAX_PROPERTY_VALUE_LENGTH = 32_768
29
-
30
26
  AT_RULE_TYPES = %w[supports layer container scope].freeze
31
27
 
32
28
  # Extract substring and force specified encoding
@@ -64,6 +60,9 @@ module Cataract
64
60
  end
65
61
 
66
62
  def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
63
+ # Type validation
64
+ raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
65
+
67
66
  # Private: Internal parsing state
68
67
  @_css = css_string.dup.freeze
69
68
  @_pos = 0
@@ -77,7 +76,8 @@ module Cataract
77
76
  selector_lists: true,
78
77
  base_uri: nil,
79
78
  absolute_paths: false,
80
- uri_resolver: nil
79
+ uri_resolver: nil,
80
+ raise_parse_errors: false
81
81
  }.merge(parser_options)
82
82
 
83
83
  # Private: Extract options to ivars to avoid repeated hash lookups in hot path
@@ -86,6 +86,34 @@ module Cataract
86
86
  @_absolute_paths = @_parser_options[:absolute_paths]
87
87
  @_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
88
88
 
89
+ # Parse error handling options - extract to ivars for hot path performance
90
+ @_raise_parse_errors = @_parser_options[:raise_parse_errors]
91
+ if @_raise_parse_errors.is_a?(Hash)
92
+ # Granular control - default all to false (opt-in)
93
+ @_check_empty_values = @_raise_parse_errors[:empty_values] || false
94
+ @_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
95
+ @_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
96
+ @_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
97
+ @_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
98
+ @_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
99
+ elsif @_raise_parse_errors == true
100
+ # Enable all error checks
101
+ @_check_empty_values = true
102
+ @_check_malformed_declarations = true
103
+ @_check_invalid_selectors = true
104
+ @_check_invalid_selector_syntax = true
105
+ @_check_malformed_at_rules = true
106
+ @_check_unclosed_blocks = true
107
+ else
108
+ # Disabled
109
+ @_check_empty_values = false
110
+ @_check_malformed_declarations = false
111
+ @_check_invalid_selectors = false
112
+ @_check_invalid_selector_syntax = false
113
+ @_check_malformed_at_rules = false
114
+ @_check_unclosed_blocks = false
115
+ end
116
+
89
117
  # Private: Internal counters
90
118
  @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
91
119
  @_next_selector_list_id = 0 # Counter for selector list IDs
@@ -139,10 +167,9 @@ module Cataract
139
167
  if has_nested_selectors?(decl_start, decl_end)
140
168
  # NESTED PATH: Parse mixed declarations + nested rules
141
169
  # Split comma-separated selectors and parse each one
142
- selectors = selector.split(',')
170
+ selectors = split_and_validate_selectors(selector, decl_start)
143
171
 
144
172
  selectors.each do |individual_selector|
145
- individual_selector.strip!
146
173
  next if individual_selector.empty?
147
174
 
148
175
  # Get rule ID for this selector
@@ -181,7 +208,7 @@ module Cataract
181
208
  declarations = parse_declarations
182
209
 
183
210
  # Split comma-separated selectors into individual rules
184
- selectors = selector.split(',')
211
+ selectors = split_and_validate_selectors(selector, decl_start)
185
212
 
186
213
  # Determine if we should track this as a selector list
187
214
  # Check boolean first to potentially avoid size() call via short-circuit evaluation
@@ -193,7 +220,6 @@ module Cataract
193
220
  end
194
221
 
195
222
  selectors.each do |individual_selector|
196
- individual_selector.strip!
197
223
  next if individual_selector.empty?
198
224
 
199
225
  rule_id = @_rule_id_counter
@@ -241,6 +267,30 @@ module Cataract
241
267
 
242
268
  private
243
269
 
270
+ # Split a comma-separated selector list into individual selector strings,
271
+ # stripping whitespace from each and validating (in strict mode) that
272
+ # none are empty. Empty entries are left in the returned array - callers
273
+ # skip them - since the emptiness check needs the original
274
+ # comma-separated count (selectors.size > 1) to know whether this is
275
+ # really a list or just a single selector, which callers no longer have
276
+ # once they've filtered.
277
+ #
278
+ # @param selector [String] Raw selector text (already trimmed as a whole)
279
+ # @param pos [Integer] Position to report in a raised ParseError
280
+ # @return [Array<String>] Individual selectors, stripped
281
+ def split_and_validate_selectors(selector, pos)
282
+ selectors = selector.split(',')
283
+ selectors.each do |individual_selector|
284
+ individual_selector.strip!
285
+
286
+ if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
287
+ raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
288
+ css: @_css, pos: pos, type: :invalid_selector_syntax)
289
+ end
290
+ end
291
+ selectors
292
+ end
293
+
244
294
  # Check if we're at end of input
245
295
  def eof?
246
296
  @_pos >= @_len
@@ -303,20 +353,119 @@ module Cataract
303
353
  end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
304
354
  end
305
355
 
356
+ # Check if a selector contains only valid CSS selector characters and sequences
357
+ # Returns true if valid, false if invalid
358
+ # Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
359
+ def valid_selector_syntax?(selector_text)
360
+ i = 0
361
+ len = selector_text.bytesize
362
+
363
+ while i < len
364
+ byte = selector_text.getbyte(i)
365
+
366
+ # Check for invalid character sequences
367
+ if i + 1 < len
368
+ next_byte = selector_text.getbyte(i + 1)
369
+ # Double dot (..) is invalid
370
+ return false if byte == BYTE_DOT && next_byte == BYTE_DOT
371
+ # Double hash (##) is invalid
372
+ return false if byte == BYTE_HASH && next_byte == BYTE_HASH
373
+ end
374
+
375
+ # Alphanumeric
376
+ if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
377
+ i += 1
378
+ next
379
+ end
380
+
381
+ # Whitespace
382
+ if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
383
+ i += 1
384
+ next
385
+ end
386
+
387
+ # Valid CSS selector special characters
388
+ case byte
389
+ when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
390
+ BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
391
+ BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
392
+ BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
393
+ BYTE_COMMA
394
+ i += 1
395
+ else
396
+ # Invalid character found
397
+ return false
398
+ end
399
+ end
400
+
401
+ true
402
+ end
403
+
404
+ # Detect and strip a trailing '!important' marker from an already-extracted,
405
+ # already-right-trimmed declaration value, in place. Shared by
406
+ # parse_single_declaration and parse_declarations so both code paths agree
407
+ # on what counts as important.
408
+ #
409
+ # The CSS2.1 grammar defines the IMPORTANT_SYM lexical token as:
410
+ # "!"({w}|{comment})*{I}{M}{P}{O}{R}{T}{A}{N}{T}
411
+ # (https://www.w3.org/TR/CSS2/grammar.html) - i.e. zero or more whitespace
412
+ # tokens are allowed between '!' and 'important'.
413
+ #
414
+ # Mutates value in place (via slice!) instead of returning a new
415
+ # [value, important] tuple, so the common case (no !important present)
416
+ # allocates nothing beyond the two getbyte/compare checks below.
417
+ #
418
+ # @param value [String] already-extracted, right-trimmed declaration value (mutated in place)
419
+ # @return [Boolean] whether an important marker was found and stripped
420
+ def extract_important!(value) # rubocop:disable Naming/PredicateMethod
421
+ return false if value.bytesize < 10
422
+
423
+ i = value.bytesize - 1
424
+ # Skip trailing whitespace
425
+ while i >= 0 && whitespace?(value.getbyte(i))
426
+ i -= 1
427
+ end
428
+
429
+ # Check for 'important' (9 chars)
430
+ return false if i < 8 || value[(i - 8), 9] != 'important'
431
+
432
+ i -= 9
433
+ # Skip whitespace between '!' and 'important'
434
+ while i >= 0 && whitespace?(value.getbyte(i))
435
+ i -= 1
436
+ end
437
+
438
+ # Check for '!'
439
+ return false unless i >= 0 && value.getbyte(i) == BYTE_BANG
440
+
441
+ # Remove everything from '!' onwards, in place
442
+ value.slice!(i..-1)
443
+ value.strip!
444
+ true
445
+ end
446
+
306
447
  # Parse a single CSS declaration (property: value)
307
448
  #
308
449
  # Performance-critical helper that parses one declaration.
309
- # Shared by parse_mixed_block, parse_declarations, and parse_declarations_block.
450
+ # Shared by parse_mixed_block and parse_declarations_block.
310
451
  #
311
452
  # @param pos [Integer] Current position in CSS string
312
453
  # @param end_pos [Integer] End position (boundary for parsing)
313
454
  # @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
314
455
  # @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
315
456
  def parse_single_declaration(pos, end_pos, parse_important)
316
- # Parse property name (scan until ':')
457
+ # Parse property name (scan until ':').
458
+ # Also stops at '{' - a property name can never legitimately contain
459
+ # one, so its presence means this is actually an unsupported/invalid
460
+ # nested selector (e.g. a bare type selector without '&') that wasn't
461
+ # recognized as nesting. Treating it as malformed here (instead of
462
+ # scanning through the '{' looking for a colon) keeps its matching '}'
463
+ # from being silently swallowed later, which was corrupting output
464
+ # with unbalanced braces.
317
465
  prop_start = pos
318
466
  while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
319
- @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
467
+ @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE &&
468
+ @_css.getbyte(pos) != BYTE_LBRACE
320
469
  pos += 1
321
470
  end
322
471
 
@@ -352,9 +501,20 @@ module Cataract
352
501
  pos += 1
353
502
  end
354
503
 
355
- # Parse value (scan until ';' or '}')
504
+ # Parse value (scan until ';' outside parens, or '}'). Paren depth
505
+ # tracking keeps a ';' inside url(...)/rgba(...) from ending the value
506
+ # early - e.g. "url(data:image/svg+xml;base64,...)".
356
507
  val_start = pos
357
- while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
508
+ paren_depth = 0
509
+ while pos < end_pos && @_css.getbyte(pos) != BYTE_RBRACE
510
+ byte = @_css.getbyte(pos)
511
+ if byte == BYTE_LPAREN
512
+ paren_depth += 1
513
+ elsif byte == BYTE_RPAREN
514
+ paren_depth -= 1
515
+ elsif byte == BYTE_SEMICOLON && paren_depth == 0
516
+ break
517
+ end
358
518
  pos += 1
359
519
  end
360
520
  val_end = pos
@@ -367,12 +527,7 @@ module Cataract
367
527
  value = byteslice_encoded(val_start, val_end - val_start)
368
528
 
369
529
  # Parse !important flag if requested
370
- important = false
371
- if parse_important && value.end_with?('!important')
372
- important = true
373
- # Remove '!important' and trailing whitespace
374
- value = value[0, value.length - 10].rstrip
375
- end
530
+ important = parse_important && extract_important!(value)
376
531
 
377
532
  # Skip semicolon if present
378
533
  pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
@@ -410,20 +565,35 @@ module Cataract
410
565
  pos += 1
411
566
  end
412
567
 
568
+ # Reached EOF without finding matching closing brace
569
+ if @_check_unclosed_blocks && depth > 0
570
+ raise ParseError.new('Unclosed block: missing closing brace',
571
+ css: @_css, pos: start_pos - 1, type: :unclosed_block)
572
+ end
573
+
413
574
  pos
414
575
  end
415
576
 
416
577
  # Parse selector (read until '{')
417
- def parse_selector
418
- start_pos = @_pos
419
-
420
- # Read until we find '{'
578
+ # Advance @_pos to the next unescaped '{', or through to EOF if none is
579
+ # found. Doesn't consume the brace itself. Shared by parse_selector and
580
+ # scan_at_rule_selector, which both need this same scan but differ in
581
+ # what they do with the boundary afterward (validation, trimming, what
582
+ # to return on EOF).
583
+ #
584
+ # @return [Boolean] true if '{' was found, false if EOF was hit first
585
+ def skip_to_opening_brace # rubocop:disable Naming/PredicateMethod
421
586
  until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
422
587
  @_pos += 1
423
588
  end
589
+ !eof?
590
+ end
591
+
592
+ def parse_selector
593
+ start_pos = @_pos
424
594
 
425
595
  # If we hit EOF without finding '{', return nil
426
- return nil if eof?
596
+ return nil unless skip_to_opening_brace
427
597
 
428
598
  # Extract selector text
429
599
  selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
@@ -433,6 +603,29 @@ module Cataract
433
603
 
434
604
  # Trim whitespace from selector (in-place to avoid allocation)
435
605
  selector_text.strip!
606
+
607
+ # Validate selector (strict mode) - only if enabled to avoid overhead
608
+ if @_check_invalid_selectors
609
+ # Check for empty selector
610
+ if selector_text.empty?
611
+ raise ParseError.new('Invalid selector: empty selector',
612
+ css: @_css, pos: start_pos, type: :invalid_selector)
613
+ end
614
+
615
+ # Check if selector starts with a combinator (>, +, ~)
616
+ first_char = selector_text.getbyte(0)
617
+ if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
618
+ raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
619
+ css: @_css, pos: start_pos, type: :invalid_selector)
620
+ end
621
+ end
622
+
623
+ # Check selector syntax (whitelist validation for invalid characters/sequences)
624
+ if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
625
+ raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
626
+ css: @_css, pos: start_pos, type: :invalid_selector_syntax)
627
+ end
628
+
436
629
  selector_text
437
630
  end
438
631
 
@@ -514,7 +707,7 @@ module Cataract
514
707
 
515
708
  # This should never happen - parent_media_query_id should always be valid
516
709
  if parent_mq.nil?
517
- raise ParserError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
710
+ raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
518
711
  end
519
712
 
520
713
  # Combine parent media query with child
@@ -664,17 +857,35 @@ module Cataract
664
857
  break
665
858
  end
666
859
 
667
- # Parse property name (read until ':')
860
+ # Parse property name (read until ':'). Also stops at '{' - a property
861
+ # name can never legitimately contain one, so its presence means this
862
+ # is actually an unsupported/invalid nested selector (e.g. a bare type
863
+ # selector without '&') that wasn't recognized as nesting. Treating it
864
+ # as malformed here (instead of scanning through the '{' looking for a
865
+ # colon) keeps its matching '}' from being silently swallowed later,
866
+ # which was corrupting output with unbalanced braces.
668
867
  property_start = @_pos
669
868
  until eof?
670
869
  byte = peek_byte
671
- break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
870
+ break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE || byte == BYTE_LBRACE
672
871
 
673
872
  @_pos += 1
674
873
  end
675
874
 
676
875
  # Skip if no colon found (malformed)
677
876
  if eof? || peek_byte != BYTE_COLON
877
+ # Check for malformed declaration (strict mode)
878
+ if @_check_malformed_declarations
879
+ property_text = byteslice_encoded(property_start, @_pos - property_start).strip
880
+ if property_text.empty?
881
+ raise ParseError.new('Malformed declaration: missing property name',
882
+ css: @_css, pos: property_start, type: :malformed_declaration)
883
+ else
884
+ raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
885
+ css: @_css, pos: property_start, type: :malformed_declaration)
886
+ end
887
+ end
888
+
678
889
  # Try to recover by finding next ; or }
679
890
  skip_to_semicolon_or_brace
680
891
  next
@@ -694,10 +905,13 @@ module Cataract
694
905
 
695
906
  skip_ws_and_comments
696
907
 
697
- # Parse value (read until ';' or '}', but respect quoted strings)
908
+ # Parse value (read until ';' outside parens, or '}', but respect
909
+ # quoted strings). Paren depth tracking keeps a ';' inside
910
+ # url(...)/rgba(...) from ending the value early - e.g.
911
+ # "url(data:image/svg+xml;base64,...)".
698
912
  value_start = @_pos
699
- important = false
700
913
  in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
914
+ paren_depth = 0
701
915
 
702
916
  until eof?
703
917
  byte = peek_byte
@@ -711,11 +925,17 @@ module Cataract
711
925
  @_pos += 1
712
926
  end
713
927
  else
714
- # Not in quote - check for terminators or quote start
715
- break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
928
+ # Not in quote - check for terminators or quote/paren start
929
+ break if byte == BYTE_RBRACE || (byte == BYTE_SEMICOLON && paren_depth == 0)
716
930
 
717
- if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE
931
+ # case/when compiles to opt_send(===) here, not opt_eq - benchmarked
932
+ # ~1.7x slower without YJIT, still slower with it.
933
+ if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE # rubocop:disable Style/CaseLikeIf
718
934
  in_quote = byte
935
+ elsif byte == BYTE_LPAREN
936
+ paren_depth += 1
937
+ elsif byte == BYTE_RPAREN
938
+ paren_depth -= 1
719
939
  end
720
940
  end
721
941
 
@@ -726,35 +946,12 @@ module Cataract
726
946
  value.strip!
727
947
 
728
948
  # Check for !important (byte-by-byte, no regexp)
729
- if value.bytesize > 10
730
- # Scan backwards to find !important
731
- i = value.bytesize - 1
732
- # Skip trailing whitespace
733
- while i >= 0
734
- b = value.getbyte(i)
735
- break unless b == BYTE_SPACE || b == BYTE_TAB
736
-
737
- i -= 1
738
- end
739
-
740
- # Check for 'important' (9 chars)
741
- if i >= 8 && value[(i - 8), 9] == 'important'
742
- i -= 9
743
- # Skip whitespace before 'important'
744
- while i >= 0
745
- b = value.getbyte(i)
746
- break unless b == BYTE_SPACE || b == BYTE_TAB
949
+ important = extract_important!(value)
747
950
 
748
- i -= 1
749
- end
750
- # Check for '!'
751
- if i >= 0 && value.getbyte(i) == BYTE_BANG
752
- important = true
753
- # Remove everything from '!' onwards (use byteslice and strip in-place)
754
- value = value.byteslice(0, i)
755
- value.strip!
756
- end
757
- end
951
+ # Check for empty value (strict mode) - only if enabled to avoid overhead
952
+ if @_check_empty_values && value.empty?
953
+ raise ParseError.new("Empty value for property '#{property}'",
954
+ css: @_css, pos: property_start, type: :empty_value)
758
955
  end
759
956
 
760
957
  # Skip semicolon if present
@@ -770,6 +967,82 @@ module Cataract
770
967
  declarations
771
968
  end
772
969
 
970
+ # Scan from at_rule_start (pointing at the '@') to the at-rule's opening
971
+ # brace, returning the trimmed selector text (e.g. "@keyframes fade").
972
+ # Leaves @_pos just past the opening brace. Shared by at-rule kinds whose
973
+ # selector is just "everything up to '{'" with no separate condition/query
974
+ # text to extract along the way (@keyframes, @font-face, and the unknown/
975
+ # default at-rule fallback).
976
+ #
977
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
978
+ # @return [String, nil] The trimmed selector, or nil if '{' was never found
979
+ def scan_at_rule_selector(at_rule_start)
980
+ return nil unless skip_to_opening_brace
981
+
982
+ selector_end = @_pos
983
+ while selector_end > at_rule_start && whitespace?(@_css.getbyte(selector_end - 1))
984
+ selector_end -= 1
985
+ end
986
+ selector = byteslice_encoded(at_rule_start, selector_end - at_rule_start)
987
+
988
+ @_pos += 1 # skip '{'
989
+ selector
990
+ end
991
+
992
+ # Merge selector_lists from a nested Parser's result into this parser,
993
+ # offsetting list ids and rule ids to avoid collisions. Shared by the
994
+ # conditional-group (@supports/@layer/@container/@scope) and @media
995
+ # at-rule handlers, which both recurse into a fresh Parser instance for
996
+ # their block content.
997
+ #
998
+ # @param nested_result [Hash] Result hash from a nested Parser#parse call
999
+ # @return [Integer] The list_id_offset used - pass through to
1000
+ # merge_nested_rules so each rule's selector_list_id stays consistent
1001
+ def merge_nested_selector_lists(nested_result)
1002
+ list_id_offset = @_next_selector_list_id
1003
+ if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
1004
+ nested_result[:_selector_lists].each do |list_id, rule_ids|
1005
+ new_list_id = list_id + list_id_offset
1006
+ offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
1007
+ @_selector_lists[new_list_id] = offsetted_rule_ids
1008
+ end
1009
+ @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
1010
+ end
1011
+ list_id_offset
1012
+ end
1013
+
1014
+ # Merge rules from a nested Parser's result into this parser's @rules,
1015
+ # renumbering ids and offsetting selector_list_id, then propagate whether
1016
+ # nesting was found inside the block up to this parser - the nested
1017
+ # parser tracked its own independent @_has_nesting, so without this a
1018
+ # rule nested inside a top-level at-rule block would carry a correct
1019
+ # parent_rule_id but the stylesheet would still report has_nesting:
1020
+ # false, causing serialization to use the flat, non-nesting-aware path
1021
+ # and print the rule's already-resolved selector as an unrelated
1022
+ # top-level rule instead of reconstructing the nested syntax.
1023
+ #
1024
+ # Yields each rule (after id/selector_list_id are updated, before it's
1025
+ # appended to @rules) for callers needing extra per-rule handling - e.g.
1026
+ # @media combining media_query_id with its outer media context.
1027
+ #
1028
+ # @param nested_result [Hash] Result hash from a nested Parser#parse call
1029
+ # @param list_id_offset [Integer] Offset from merge_nested_selector_lists
1030
+ def merge_nested_rules(nested_result, list_id_offset)
1031
+ nested_result[:rules].each do |rule|
1032
+ rule.id = @_rule_id_counter
1033
+ if rule.is_a?(Rule) && rule.selector_list_id
1034
+ rule.selector_list_id += list_id_offset
1035
+ end
1036
+
1037
+ yield rule if block_given?
1038
+
1039
+ @_rule_id_counter += 1
1040
+ @rules << rule
1041
+ end
1042
+
1043
+ @_has_nesting ||= nested_result[:_has_nesting] # rubocop:disable Naming/MemoizedInstanceVariableName -- not memoization, propagating a flag from the nested parse
1044
+ end
1045
+
773
1046
  # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
774
1047
  # Translated from C: see ext/cataract/css_parser.c lines 962-1128
775
1048
  def parse_at_rule
@@ -788,401 +1061,363 @@ module Cataract
788
1061
  at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
789
1062
 
790
1063
  # Handle @charset specially - it's just @charset "value";
791
- if at_rule_name == 'charset'
792
- skip_ws_and_comments
793
- # Read until semicolon
794
- value_start = @_pos
795
- while !eof? && peek_byte != BYTE_SEMICOLON
796
- @_pos += 1
797
- end
798
-
799
- charset_value = byteslice_encoded(value_start, @_pos - value_start)
800
- charset_value.strip!
801
- # Remove quotes
802
- @charset = charset_value.delete('"\'')
803
-
804
- @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
805
- return
806
- end
1064
+ return parse_charset_at_rule if at_rule_name == 'charset'
807
1065
 
808
1066
  # Handle @import - must come before rules (except @charset)
809
- if at_rule_name == 'import'
810
- # If we've already seen a rule, this @import is invalid
811
- if @rules.size > 0
812
- warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
813
- # Skip to semicolon
814
- while !eof? && peek_byte != BYTE_SEMICOLON
815
- @_pos += 1
816
- end
817
- @_pos += 1 if peek_byte == BYTE_SEMICOLON
818
- return
819
- end
820
-
821
- parse_import_statement
822
- return
823
- end
1067
+ return parse_import_at_rule if at_rule_name == 'import'
824
1068
 
825
1069
  # Handle conditional group at-rules: @supports, @layer, @container, @scope
826
1070
  # These behave like @media but don't affect media context
827
- if AT_RULE_TYPES.include?(at_rule_name)
828
- skip_ws_and_comments
1071
+ return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
829
1072
 
830
- # Skip to opening brace
831
- while !eof? && peek_byte != BYTE_LBRACE
832
- @_pos += 1
833
- end
1073
+ # Handle @media specially - parse content and track in media_index
1074
+ return parse_media_at_rule if at_rule_name == 'media'
834
1075
 
835
- return if eof? || peek_byte != BYTE_LBRACE
1076
+ # Check for @keyframes (contains <rule-list>)
1077
+ is_keyframes = at_rule_name == 'keyframes' ||
1078
+ at_rule_name == '-webkit-keyframes' ||
1079
+ at_rule_name == '-moz-keyframes'
1080
+ return parse_keyframes_at_rule(at_rule_start) if is_keyframes
836
1081
 
837
- @_pos += 1 # skip '{'
1082
+ # Check for @font-face (contains <declaration-list>)
1083
+ return parse_font_face_at_rule(at_rule_start) if at_rule_name == 'font-face'
838
1084
 
839
- # Find matching closing brace
840
- block_start = @_pos
841
- block_end = find_matching_brace(@_pos)
1085
+ # Unknown at-rule (@property, @page, @counter-style, etc.)
1086
+ # Treat as a regular selector-based rule with declarations
1087
+ parse_unknown_at_rule(at_rule_start)
1088
+ end
842
1089
 
843
- # Check depth before recursing
844
- if @_depth + 1 > MAX_PARSE_DEPTH
845
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
846
- end
1090
+ # @charset "value"; - stores the value and consumes through the semicolon.
1091
+ def parse_charset_at_rule
1092
+ skip_ws_and_comments
1093
+ # Read until semicolon
1094
+ value_start = @_pos
1095
+ while !eof? && peek_byte != BYTE_SEMICOLON
1096
+ @_pos += 1
1097
+ end
847
1098
 
848
- # Recursively parse block content (preserve parent media context)
849
- nested_parser = Parser.new(
850
- byteslice_encoded(block_start, block_end - block_start),
851
- parser_options: @_parser_options,
852
- parent_media_sym: @_parent_media_sym,
853
- depth: @_depth + 1
854
- )
855
-
856
- nested_result = nested_parser.parse
857
-
858
- # Merge nested selector_lists with offsetted IDs
859
- list_id_offset = @_next_selector_list_id
860
- if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
861
- nested_result[:_selector_lists].each do |list_id, rule_ids|
862
- new_list_id = list_id + list_id_offset
863
- offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
864
- @_selector_lists[new_list_id] = offsetted_rule_ids
865
- end
866
- @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
867
- end
1099
+ charset_value = byteslice_encoded(value_start, @_pos - value_start)
1100
+ charset_value.strip!
1101
+ # Remove quotes
1102
+ @charset = charset_value.delete('"\'')
868
1103
 
869
- # NOTE: We no longer build media_index during parse
870
- # It will be built from MediaQuery objects after import resolution
1104
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1105
+ end
871
1106
 
872
- # Add nested rules to main rules array
873
- nested_result[:rules].each do |rule|
874
- rule.id = @_rule_id_counter
875
- # Update selector_list_id if applicable
876
- if rule.is_a?(Rule) && rule.selector_list_id
877
- rule.selector_list_id += list_id_offset
878
- end
879
- @_rule_id_counter += 1
880
- @rules << rule
1107
+ # @import must appear before all rules (except @charset) per CSS spec -
1108
+ # anything else is invalid and gets skipped with a warning instead of
1109
+ # being parsed as an import.
1110
+ def parse_import_at_rule
1111
+ # If we've already seen a rule, this @import is invalid
1112
+ if @rules.size > 0
1113
+ warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
1114
+ # Skip to semicolon
1115
+ while !eof? && peek_byte != BYTE_SEMICOLON
1116
+ @_pos += 1
881
1117
  end
882
-
883
- # Move position past the closing brace
884
- @_pos = block_end
885
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
886
-
1118
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON
887
1119
  return
888
1120
  end
889
1121
 
890
- # Handle @media specially - parse content and track in media_index
891
- if at_rule_name == 'media'
892
- skip_ws_and_comments
1122
+ parse_import_statement
1123
+ end
893
1124
 
894
- # Find media query (up to opening brace)
895
- mq_start = @_pos
896
- while !eof? && peek_byte != BYTE_LBRACE
897
- @_pos += 1
898
- end
1125
+ # @supports, @layer, @container, @scope - behave like @media but don't
1126
+ # affect media context, so unlike parse_media_at_rule there's no media
1127
+ # query/media_query_id merging to do on top of the shared nested-parser
1128
+ # result merge.
1129
+ #
1130
+ # @param at_rule_name [String] The at-rule name (e.g. "supports"), used
1131
+ # to decide whether a condition is required and for error messages
1132
+ def parse_conditional_group_at_rule(at_rule_name)
1133
+ skip_ws_and_comments
899
1134
 
900
- return if eof? || peek_byte != BYTE_LBRACE
1135
+ # Remember start of condition for error reporting
1136
+ condition_start = @_pos
901
1137
 
902
- mq_end = @_pos
903
- # Trim trailing whitespace
904
- while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
905
- mq_end -= 1
906
- end
1138
+ # Skip to opening brace
1139
+ condition_end = @_pos
1140
+ while !eof? && peek_byte != BYTE_LBRACE
1141
+ condition_end = @_pos
1142
+ @_pos += 1
1143
+ end
907
1144
 
908
- child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
909
- # Keep media query exactly as written - parentheses are required per CSS spec
910
- child_media_string.strip!
911
- child_media_sym = child_media_string.to_sym
912
-
913
- # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
914
- # Per W3C spec, comma acts as logical OR - each query is independent
915
- media_query_strings = child_media_string.split(',').map(&:strip)
916
-
917
- # Create MediaQuery objects for each query in the list
918
- media_query_ids = []
919
- media_query_strings.each do |query_string|
920
- media_type, media_conditions = parse_media_query_parts(query_string)
921
- media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
922
- @media_queries << media_query
923
- media_query_ids << @_media_query_id_counter
924
- @_media_query_id_counter += 1
925
- end
1145
+ return if eof? || peek_byte != BYTE_LBRACE
926
1146
 
927
- # If multiple queries, track them as a list for serialization
928
- if media_query_ids.size > 1
929
- @_media_query_lists[@_next_media_query_list_id] = media_query_ids
930
- @_next_media_query_list_id += 1
1147
+ # Validate condition (strict mode) - @supports, @container, @scope require conditions
1148
+ if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
1149
+ condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
1150
+ if condition_str.empty?
1151
+ raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
1152
+ css: @_css, pos: condition_start, type: :malformed_at_rule)
931
1153
  end
1154
+ end
932
1155
 
933
- # Use first query ID as the primary one for rules in this block
934
- current_media_query_id = media_query_ids.first
935
-
936
- # Combine with parent media context
937
- combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1156
+ @_pos += 1 # skip '{'
938
1157
 
939
- # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
940
- # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
941
- # MediaQuery combining for nested @media happens in parse_mixed_block instead.
942
- # So this is just an alias to current_media_query_id.
943
- combined_media_query_id = current_media_query_id
1158
+ # Find matching closing brace
1159
+ block_start = @_pos
1160
+ block_end = find_matching_brace(@_pos)
944
1161
 
945
- # Check media query limit
946
- unless @media_index.key?(combined_media_sym)
947
- @_media_query_count += 1
948
- if @_media_query_count > MAX_MEDIA_QUERIES
949
- raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
950
- end
951
- end
1162
+ # Check depth before recursing
1163
+ if @_depth + 1 > MAX_PARSE_DEPTH
1164
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1165
+ end
952
1166
 
953
- @_pos += 1 # skip '{'
1167
+ # Recursively parse block content (preserve parent media context)
1168
+ nested_parser = Parser.new(
1169
+ byteslice_encoded(block_start, block_end - block_start),
1170
+ parser_options: @_parser_options,
1171
+ parent_media_sym: @_parent_media_sym,
1172
+ depth: @_depth + 1
1173
+ )
954
1174
 
955
- # Find matching closing brace
956
- block_start = @_pos
957
- block_end = find_matching_brace(@_pos)
1175
+ nested_result = nested_parser.parse
958
1176
 
959
- # Check depth before recursing
960
- if @_depth + 1 > MAX_PARSE_DEPTH
961
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
962
- end
963
-
964
- # Parse the content with the combined media context
965
- # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
966
- # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
967
- nested_parser = Parser.new(
968
- byteslice_encoded(block_start, block_end - block_start),
969
- parser_options: @_parser_options,
970
- parent_media_sym: combined_media_sym,
971
- depth: @_depth + 1
972
- )
973
-
974
- nested_result = nested_parser.parse
975
-
976
- # Merge nested selector_lists with offsetted IDs
977
- list_id_offset = @_next_selector_list_id
978
- if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
979
- nested_result[:_selector_lists].each do |list_id, rule_ids|
980
- new_list_id = list_id + list_id_offset
981
- offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
982
- @_selector_lists[new_list_id] = offsetted_rule_ids
983
- end
984
- @_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
985
- end
1177
+ # NOTE: We no longer build media_index during parse
1178
+ # It will be built from MediaQuery objects after import resolution
1179
+ list_id_offset = merge_nested_selector_lists(nested_result)
1180
+ merge_nested_rules(nested_result, list_id_offset)
986
1181
 
987
- # Merge nested MediaQuery objects with offsetted IDs
988
- mq_id_offset = @_media_query_id_counter
989
- if nested_result[:media_queries] && !nested_result[:media_queries].empty?
990
- nested_result[:media_queries].each do |mq|
991
- # Create new MediaQuery with offsetted ID
992
- new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
993
- @media_queries << new_mq
994
- end
995
- @_media_query_id_counter += nested_result[:media_queries].size
996
- end
1182
+ # Move position past the closing brace
1183
+ @_pos = block_end
1184
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1185
+ end
997
1186
 
998
- # Merge nested media_query_lists with offsetted IDs
999
- if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1000
- nested_result[:_media_query_lists].each do |list_id, mq_ids|
1001
- # Offset the list_id and media_query_ids
1002
- new_list_id = list_id + @_next_media_query_list_id
1003
- offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1004
- @_media_query_lists[new_list_id] = offsetted_mq_ids
1005
- end
1006
- @_next_media_query_list_id += nested_result[:_media_query_lists].size
1007
- end
1187
+ # @media - parses the media query, recurses into a nested Parser for the
1188
+ # block content (combining its media context with any parent), and
1189
+ # merges the result. Unlike parse_conditional_group_at_rule, this also
1190
+ # merges MediaQuery objects/lists and combines each nested rule's
1191
+ # media_query_id with this block's own media context.
1192
+ def parse_media_at_rule
1193
+ skip_ws_and_comments
1008
1194
 
1009
- # Merge nested media_index into ours (for nested @media)
1010
- # Note: We no longer build media_index during parse
1011
- # It will be built from MediaQuery objects after import resolution
1195
+ # Find media query (up to opening brace)
1196
+ mq_start = @_pos
1197
+ return unless skip_to_opening_brace
1012
1198
 
1013
- # Add nested rules to main rules array
1014
- nested_result[:rules].each do |rule|
1015
- rule.id = @_rule_id_counter
1016
- # Update selector_list_id if applicable
1017
- if rule.is_a?(Rule) && rule.selector_list_id
1018
- rule.selector_list_id += list_id_offset
1019
- end
1199
+ mq_end = @_pos
1200
+ # Trim trailing whitespace
1201
+ while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
1202
+ mq_end -= 1
1203
+ end
1020
1204
 
1021
- # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1022
- if rule.media_query_id
1023
- # Nested parser assigned a media_query_id - need to combine with our context
1024
- nested_mq_id = rule.media_query_id + mq_id_offset
1025
- nested_mq = @media_queries[nested_mq_id]
1026
-
1027
- # Combine nested media query with our media context
1028
- if nested_mq && combined_media_query_id
1029
- outer_mq = @media_queries[combined_media_query_id]
1030
- if outer_mq
1031
- # Combine media queries directly without string building
1032
- combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1033
- combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1034
- @media_queries << combined_mq
1035
- rule.media_query_id = @_media_query_id_counter
1036
- @_media_query_id_counter += 1
1037
- else
1038
- rule.media_query_id = nested_mq_id
1039
- end
1040
- else
1041
- rule.media_query_id = nested_mq_id
1042
- end
1043
- elsif rule.respond_to?(:media_query_id=)
1044
- # Assign the combined media_query_id if no media_query_id set
1045
- # (applies to both Rule and AtRule)
1046
- rule.media_query_id = combined_media_query_id
1047
- end
1205
+ child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
1206
+ # Keep media query exactly as written - parentheses are required per CSS spec
1207
+ child_media_string.strip!
1048
1208
 
1049
- # NOTE: We no longer build media_index during parse
1050
- # It will be built from MediaQuery objects after import resolution
1209
+ # Validate @media has a query (strict mode)
1210
+ if @_check_malformed_at_rules && child_media_string.empty?
1211
+ raise ParseError.new('Malformed @media: missing media query or condition',
1212
+ css: @_css, pos: mq_start, type: :malformed_at_rule)
1213
+ end
1051
1214
 
1052
- @_rule_id_counter += 1
1053
- @rules << rule
1054
- end
1215
+ child_media_sym = child_media_string.to_sym
1055
1216
 
1056
- # Move position past the closing brace
1057
- @_pos = block_end
1058
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1217
+ # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1218
+ # Per W3C spec, comma acts as logical OR - each query is independent
1219
+ media_query_strings = child_media_string.split(',').map(&:strip)
1059
1220
 
1060
- return
1221
+ # Create MediaQuery objects for each query in the list
1222
+ media_query_ids = []
1223
+ media_query_strings.each do |query_string|
1224
+ media_type, media_conditions = parse_media_query_parts(query_string)
1225
+ media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
1226
+ @media_queries << media_query
1227
+ media_query_ids << @_media_query_id_counter
1228
+ @_media_query_id_counter += 1
1061
1229
  end
1062
1230
 
1063
- # Check for @keyframes (contains <rule-list>)
1064
- is_keyframes = at_rule_name == 'keyframes' ||
1065
- at_rule_name == '-webkit-keyframes' ||
1066
- at_rule_name == '-moz-keyframes'
1231
+ # If multiple queries, track them as a list for serialization
1232
+ if media_query_ids.size > 1
1233
+ @_media_query_lists[@_next_media_query_list_id] = media_query_ids
1234
+ @_next_media_query_list_id += 1
1235
+ end
1067
1236
 
1068
- if is_keyframes
1069
- # Build full selector string: "@keyframes fade"
1070
- selector_start = at_rule_start # Points to '@'
1237
+ # Use first query ID as the primary one for rules in this block
1238
+ current_media_query_id = media_query_ids.first
1071
1239
 
1072
- # Skip to opening brace
1073
- while !eof? && peek_byte != BYTE_LBRACE
1074
- @_pos += 1
1075
- end
1240
+ # Combine with parent media context
1241
+ combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1076
1242
 
1077
- return if eof? || peek_byte != BYTE_LBRACE
1243
+ # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
1244
+ # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
1245
+ # MediaQuery combining for nested @media happens in parse_mixed_block instead.
1246
+ # So this is just an alias to current_media_query_id.
1247
+ combined_media_query_id = current_media_query_id
1078
1248
 
1079
- selector_end = @_pos
1080
- # Trim trailing whitespace
1081
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1082
- selector_end -= 1
1249
+ # Check media query limit
1250
+ unless @media_index.key?(combined_media_sym)
1251
+ @_media_query_count += 1
1252
+ if @_media_query_count > MAX_MEDIA_QUERIES
1253
+ raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
1083
1254
  end
1084
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1085
-
1086
- @_pos += 1 # skip '{'
1255
+ end
1087
1256
 
1088
- # Find matching closing brace
1089
- block_start = @_pos
1090
- block_end = find_matching_brace(@_pos)
1257
+ @_pos += 1 # skip '{'
1091
1258
 
1092
- # Check depth before recursing
1093
- if @_depth + 1 > MAX_PARSE_DEPTH
1094
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1095
- end
1259
+ # Find matching closing brace
1260
+ block_start = @_pos
1261
+ block_end = find_matching_brace(@_pos)
1096
1262
 
1097
- # Parse keyframe blocks as rules (0%/from/to etc)
1098
- # Create a nested parser context
1099
- nested_parser = Parser.new(
1100
- byteslice_encoded(block_start, block_end - block_start),
1101
- parser_options: @_parser_options,
1102
- depth: @_depth + 1
1103
- )
1104
- nested_result = nested_parser.parse
1105
- content = nested_result[:rules]
1106
-
1107
- # Move position past the closing brace
1108
- @_pos = block_end
1109
- # The closing brace should be at block_end
1110
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1111
-
1112
- # Get rule ID and increment
1113
- rule_id = @_rule_id_counter
1114
- @_rule_id_counter += 1
1263
+ # Check depth before recursing
1264
+ if @_depth + 1 > MAX_PARSE_DEPTH
1265
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1266
+ end
1115
1267
 
1116
- # Create AtRule with nested rules
1117
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1118
- @rules << at_rule
1268
+ # Parse the content with the combined media context
1269
+ # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1270
+ # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
1271
+ nested_parser = Parser.new(
1272
+ byteslice_encoded(block_start, block_end - block_start),
1273
+ parser_options: @_parser_options,
1274
+ parent_media_sym: combined_media_sym,
1275
+ depth: @_depth + 1
1276
+ )
1119
1277
 
1120
- return
1121
- end
1278
+ nested_result = nested_parser.parse
1122
1279
 
1123
- # Check for @font-face (contains <declaration-list>)
1124
- if at_rule_name == 'font-face'
1125
- # Build selector string: "@font-face"
1126
- selector_start = at_rule_start # Points to '@'
1280
+ list_id_offset = merge_nested_selector_lists(nested_result)
1127
1281
 
1128
- # Skip to opening brace
1129
- while !eof? && peek_byte != BYTE_LBRACE
1130
- @_pos += 1
1282
+ # Merge nested MediaQuery objects with offsetted IDs
1283
+ mq_id_offset = @_media_query_id_counter
1284
+ if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1285
+ nested_result[:media_queries].each do |mq|
1286
+ # Create new MediaQuery with offsetted ID
1287
+ new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
1288
+ @media_queries << new_mq
1131
1289
  end
1290
+ @_media_query_id_counter += nested_result[:media_queries].size
1291
+ end
1132
1292
 
1133
- return if eof? || peek_byte != BYTE_LBRACE
1293
+ # Merge nested media_query_lists with offsetted IDs
1294
+ if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1295
+ nested_result[:_media_query_lists].each do |list_id, mq_ids|
1296
+ # Offset the list_id and media_query_ids
1297
+ new_list_id = list_id + @_next_media_query_list_id
1298
+ offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1299
+ @_media_query_lists[new_list_id] = offsetted_mq_ids
1300
+ end
1301
+ @_next_media_query_list_id += nested_result[:_media_query_lists].size
1302
+ end
1134
1303
 
1135
- selector_end = @_pos
1136
- # Trim trailing whitespace
1137
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1138
- selector_end -= 1
1304
+ # Merge nested media_index into ours (for nested @media)
1305
+ # Note: We no longer build media_index during parse
1306
+ # It will be built from MediaQuery objects after import resolution
1307
+
1308
+ merge_nested_rules(nested_result, list_id_offset) do |rule|
1309
+ # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1310
+ if rule.media_query_id
1311
+ # Nested parser assigned a media_query_id - need to combine with our context
1312
+ nested_mq_id = rule.media_query_id + mq_id_offset
1313
+ nested_mq = @media_queries[nested_mq_id]
1314
+
1315
+ # Combine nested media query with our media context
1316
+ if nested_mq && combined_media_query_id
1317
+ outer_mq = @media_queries[combined_media_query_id]
1318
+ if outer_mq
1319
+ # Combine media queries directly without string building
1320
+ combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
1321
+ combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
1322
+ @media_queries << combined_mq
1323
+ rule.media_query_id = @_media_query_id_counter
1324
+ @_media_query_id_counter += 1
1325
+ else
1326
+ rule.media_query_id = nested_mq_id
1327
+ end
1328
+ else
1329
+ rule.media_query_id = nested_mq_id
1330
+ end
1331
+ elsif rule.respond_to?(:media_query_id=)
1332
+ # Assign the combined media_query_id if no media_query_id set
1333
+ # (applies to both Rule and AtRule)
1334
+ rule.media_query_id = combined_media_query_id
1139
1335
  end
1140
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1336
+ end
1141
1337
 
1142
- @_pos += 1 # skip '{'
1338
+ # Move position past the closing brace
1339
+ @_pos = block_end
1340
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1341
+ end
1143
1342
 
1144
- # Find matching closing brace
1145
- decl_start = @_pos
1146
- decl_end = find_matching_brace(@_pos)
1343
+ # @keyframes (contains a <rule-list> of percentage/from/to blocks)
1344
+ #
1345
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1346
+ def parse_keyframes_at_rule(at_rule_start)
1347
+ # Build full selector string: "@keyframes fade"
1348
+ selector = scan_at_rule_selector(at_rule_start)
1349
+ return if selector.nil?
1350
+
1351
+ # Find matching closing brace
1352
+ block_start = @_pos
1353
+ block_end = find_matching_brace(@_pos)
1354
+
1355
+ # Check depth before recursing
1356
+ if @_depth + 1 > MAX_PARSE_DEPTH
1357
+ raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1358
+ end
1147
1359
 
1148
- # Parse declarations
1149
- content = parse_declarations_block(decl_start, decl_end)
1360
+ # Parse keyframe blocks as rules (0%/from/to etc)
1361
+ # Create a nested parser context
1362
+ nested_parser = Parser.new(
1363
+ byteslice_encoded(block_start, block_end - block_start),
1364
+ parser_options: @_parser_options,
1365
+ depth: @_depth + 1
1366
+ )
1367
+ nested_result = nested_parser.parse
1368
+ content = nested_result[:rules]
1150
1369
 
1151
- # Move position past the closing brace
1152
- @_pos = decl_end
1153
- # The closing brace should be at decl_end
1154
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1370
+ # Move position past the closing brace
1371
+ @_pos = block_end
1372
+ # The closing brace should be at block_end
1373
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1155
1374
 
1156
- # Get rule ID and increment
1157
- rule_id = @_rule_id_counter
1158
- @_rule_id_counter += 1
1375
+ # Get rule ID and increment
1376
+ rule_id = @_rule_id_counter
1377
+ @_rule_id_counter += 1
1159
1378
 
1160
- # Create AtRule with declarations
1161
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1162
- @rules << at_rule
1379
+ # Create AtRule with nested rules
1380
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1381
+ @rules << at_rule
1382
+ end
1163
1383
 
1164
- return
1165
- end
1384
+ # @font-face (contains a <declaration-list>)
1385
+ #
1386
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1387
+ def parse_font_face_at_rule(at_rule_start)
1388
+ # Build selector string: "@font-face"
1389
+ selector = scan_at_rule_selector(at_rule_start)
1390
+ return if selector.nil?
1166
1391
 
1167
- # Unknown at-rule (@property, @page, @counter-style, etc.)
1168
- # Treat as a regular selector-based rule with declarations
1169
- selector_start = at_rule_start # Points to '@'
1392
+ # Find matching closing brace
1393
+ decl_start = @_pos
1394
+ decl_end = find_matching_brace(@_pos)
1170
1395
 
1171
- # Skip to opening brace
1172
- until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1173
- @_pos += 1
1174
- end
1396
+ # Parse declarations
1397
+ content = parse_declarations_block(decl_start, decl_end)
1175
1398
 
1176
- return if eof? || peek_byte != BYTE_LBRACE
1399
+ # Move position past the closing brace
1400
+ @_pos = decl_end
1401
+ # The closing brace should be at decl_end
1402
+ @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1177
1403
 
1178
- selector_end = @_pos
1179
- # Trim trailing whitespace
1180
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1181
- selector_end -= 1
1182
- end
1183
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1404
+ # Get rule ID and increment
1405
+ rule_id = @_rule_id_counter
1406
+ @_rule_id_counter += 1
1184
1407
 
1185
- @_pos += 1 # skip '{'
1408
+ # Create AtRule with declarations
1409
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1410
+ @rules << at_rule
1411
+ end
1412
+
1413
+ # Unknown at-rule (@property, @page, @counter-style, etc.) - treated as a
1414
+ # regular selector-based rule with declarations, since this parser has no
1415
+ # special handling for it.
1416
+ #
1417
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1418
+ def parse_unknown_at_rule(at_rule_start)
1419
+ selector = scan_at_rule_selector(at_rule_start)
1420
+ return if selector.nil?
1186
1421
 
1187
1422
  # Parse declarations
1188
1423
  declarations = parse_declarations