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
@@ -12,6 +12,11 @@
12
12
 
13
13
  #include "cataract.h"
14
14
  #include <string.h>
15
+ #include <stdint.h>
16
+
17
+ // Use uint8_t for boolean flags to reduce struct size and improve cache efficiency
18
+ // (int is 4 bytes, uint8_t is 1 byte - saves 27 bytes across 9 flags)
19
+ #define BOOLEAN uint8_t
15
20
 
16
21
  // Parser context passed through recursive calls
17
22
  typedef struct {
@@ -26,16 +31,55 @@ typedef struct {
26
31
  int media_query_id_counter; // Next MediaQuery ID (0-indexed)
27
32
  int next_media_query_list_id; // Next media query list ID (0-indexed)
28
33
  int media_query_count; // Safety limit for media queries
29
- st_table *media_cache; // Parse-time cache: string => parsed media types
30
- int has_nesting; // Set to 1 if any nested rules are created
31
- int selector_lists_enabled; // Parser option: track selector lists (1=enabled, 0=disabled)
32
- int depth; // Current recursion depth (safety limit)
34
+ BOOLEAN has_nesting; // Set to 1 if any nested rules are created
35
+ BOOLEAN selector_lists_enabled; // Parser option: track selector lists (1=enabled, 0=disabled)
36
+ BOOLEAN depth; // Current recursion depth (safety limit)
33
37
  // URL conversion options
34
38
  VALUE base_uri; // Base URI for resolving relative URLs (Qnil if disabled)
35
39
  VALUE uri_resolver; // Proc to call for URL resolution (Qnil for default)
36
- int absolute_paths; // Whether to convert relative URLs to absolute
40
+ BOOLEAN absolute_paths; // Whether to convert relative URLs to absolute
41
+ // Parse error checking options
42
+ VALUE css_string; // Full CSS string for error position calculation
43
+ BOOLEAN check_empty_values; // Raise error on empty declaration values
44
+ BOOLEAN check_malformed_declarations; // Raise error on declarations without colons
45
+ BOOLEAN check_invalid_selectors; // Raise error on empty/malformed selectors
46
+ BOOLEAN check_invalid_selector_syntax; // Raise error on syntax violations (.. ## etc)
47
+ BOOLEAN check_malformed_at_rules; // Raise error on @media/@supports without conditions
48
+ BOOLEAN check_unclosed_blocks; // Raise error on missing closing braces
37
49
  } ParserContext;
38
50
 
51
+ // Build a fresh ParserContext for a nested parse (e.g. @keyframes contents).
52
+ // The child gets its own rule/media collections, but explicitly inherits the
53
+ // parent's error-checking flags and URL/error-reporting fields instead of
54
+ // relying on zero-initialization, which previously left check_* flags
55
+ // disabled and css_string/base_uri NULL regardless of the parent's settings.
56
+ static inline ParserContext init_child_context(ParserContext *parent) {
57
+ ParserContext child = {0};
58
+
59
+ child.rules_array = rb_ary_new();
60
+ child.media_index = rb_hash_new();
61
+ child.selector_lists = rb_hash_new();
62
+ child.imports_array = rb_ary_new();
63
+ child.media_queries = rb_ary_new();
64
+ child.media_query_lists = rb_hash_new();
65
+
66
+ child.selector_lists_enabled = parent->selector_lists_enabled;
67
+
68
+ child.base_uri = parent->base_uri;
69
+ child.uri_resolver = parent->uri_resolver;
70
+ child.absolute_paths = parent->absolute_paths;
71
+
72
+ child.css_string = parent->css_string;
73
+ child.check_empty_values = parent->check_empty_values;
74
+ child.check_malformed_declarations = parent->check_malformed_declarations;
75
+ child.check_invalid_selectors = parent->check_invalid_selectors;
76
+ child.check_invalid_selector_syntax = parent->check_invalid_selector_syntax;
77
+ child.check_malformed_at_rules = parent->check_malformed_at_rules;
78
+ child.check_unclosed_blocks = parent->check_unclosed_blocks;
79
+
80
+ return child;
81
+ }
82
+
39
83
  // Macro to skip CSS comments /* ... */
40
84
  // Usage: SKIP_COMMENT(p, end) where p is current position, end is limit
41
85
  // Side effect: advances p past the comment and continues to next iteration
@@ -63,6 +107,20 @@ static inline const char* find_matching_brace(const char *start, const char *end
63
107
  return p;
64
108
  }
65
109
 
110
+ // Find matching closing brace with strict error checking
111
+ // Input: start = position after opening '{', end = limit, check_unclosed = whether to raise error
112
+ // Returns: pointer to matching '}' (raises error if not found and check_unclosed is true)
113
+ static inline const char* find_matching_brace_strict(const char *start, const char *end, int check_unclosed) {
114
+ const char *closing_brace = find_matching_brace(start, end);
115
+
116
+ // Check if we found the closing brace
117
+ if (check_unclosed && closing_brace >= end) {
118
+ rb_raise(eParseError, "Unclosed block: missing closing brace");
119
+ }
120
+
121
+ return closing_brace;
122
+ }
123
+
66
124
  // Find matching closing paren
67
125
  // Input: start = position after opening '(', end = limit
68
126
  // Returns: pointer to matching ')' (or end if not found)
@@ -78,6 +136,99 @@ static inline const char* find_matching_paren(const char *start, const char *end
78
136
  return p;
79
137
  }
80
138
 
139
+ // Helper function to raise ParseError with automatic position calculation
140
+ // Does not return - raises error and exits
141
+ __attribute__((noreturn))
142
+ static void raise_parse_error_at(ParserContext *ctx, const char *error_pos, const char *message, const char *error_type) {
143
+ const char *css = RSTRING_PTR(ctx->css_string);
144
+ long pos = error_pos - css;
145
+
146
+ // Build keyword args hash
147
+ VALUE kwargs = rb_hash_new();
148
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
149
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(pos));
150
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern(error_type)));
151
+
152
+ // Create ParseError instance
153
+ VALUE msg_str = rb_str_new_cstr(message);
154
+ VALUE argv[2] = {msg_str, kwargs};
155
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
156
+
157
+ // Raise the error
158
+ rb_exc_raise(error);
159
+ }
160
+
161
+ // Check if a selector contains only valid CSS selector characters and sequences
162
+ // Returns 1 if valid, 0 if invalid
163
+ // Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
164
+ static inline int is_valid_selector(const char *start, const char *end) {
165
+ const char *p = start;
166
+ while (p < end) {
167
+ unsigned char c = (unsigned char)*p;
168
+
169
+ // Check for invalid character sequences
170
+ if (p + 1 < end) {
171
+ // Double dot (..) is invalid
172
+ if (c == '.' && *(p + 1) == '.') {
173
+ return 0;
174
+ }
175
+ // Double hash (##) is invalid
176
+ if (c == '#' && *(p + 1) == '#') {
177
+ return 0;
178
+ }
179
+ }
180
+
181
+ // Alphanumeric
182
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
183
+ p++;
184
+ continue;
185
+ }
186
+
187
+ // Whitespace
188
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
189
+ p++;
190
+ continue;
191
+ }
192
+
193
+ // Valid CSS selector special characters
194
+ switch (c) {
195
+ case '-': // Hyphen (in identifiers, attribute selectors)
196
+ case '_': // Underscore (in identifiers)
197
+ case '.': // Class selector
198
+ case '#': // ID selector
199
+ case '[': // Attribute selector start
200
+ case ']': // Attribute selector end
201
+ case ':': // Pseudo-class/element (:: is valid for pseudo-elements)
202
+ case '*': // Universal selector, attribute operator
203
+ case '>': // Child combinator
204
+ case '+': // Adjacent sibling combinator
205
+ case '~': // General sibling combinator
206
+ case '(': // Pseudo-class function
207
+ case ')': // Pseudo-class function end
208
+ case '\'': // String in attribute selector
209
+ case '"': // String in attribute selector
210
+ case '=': // Attribute operator
211
+ case '^': // Attribute operator ^=
212
+ case '$': // Attribute operator $=
213
+ case '|': // Attribute operator |=, namespace separator
214
+ case '\\': // Escape character
215
+ case '&': // Nesting selector
216
+ case '%': // Sometimes used in selectors
217
+ case '/': // Sometimes used in selectors
218
+ case '!': // Negation (though rare)
219
+ case ',': // List separator (shouldn't be here after splitting, but allow it)
220
+ p++;
221
+ break;
222
+
223
+ default:
224
+ // Invalid character found
225
+ return 0;
226
+ }
227
+ }
228
+
229
+ return 1;
230
+ }
231
+
81
232
  // Lowercase property name (CSS property names are ASCII-only)
82
233
  // Non-static so merge_new.c can use it
83
234
  VALUE lowercase_property(VALUE property_str) {
@@ -177,7 +328,7 @@ static VALUE resolve_nested_selector(VALUE parent_selector, const char *nested_s
177
328
  long parent_len = RSTRING_LEN(parent_selector);
178
329
 
179
330
  // Check if nested selector contains &
180
- int has_ampersand = 0;
331
+ BOOLEAN has_ampersand = 0;
181
332
  for (long i = 0; i < nested_len; i++) {
182
333
  if (nested_sel[i] == '&') {
183
334
  has_ampersand = 1;
@@ -598,79 +749,94 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
598
749
  }
599
750
  if (pos >= end) break;
600
751
 
601
- // Find property (up to colon)
602
- // Example: "color: red; ..."
603
- // ^pos ^pos (at :)
752
+ // A property name can never legitimately contain '{' - its presence
753
+ // means this is actually an unsupported/invalid nested selector (e.g.
754
+ // a bare type selector without '&') that has_nested_selectors() didn't
755
+ // recognize as nesting. Treating it as malformed here (instead of
756
+ // scanning through the '{' looking for a colon) keeps its matching
757
+ // '}' from being silently swallowed later, which was corrupting
758
+ // output with unbalanced braces. So stop_prop_scan_early=1.
604
759
  const char *prop_start = pos;
605
- while (pos < end && *pos != ':' && *pos != ';') pos++;
606
-
607
- // Malformed declaration - skip to next semicolon to recover
608
- if (pos >= end || *pos != ':') {
760
+ struct declaration_span span;
761
+ if (!parse_one_declaration(&pos, end, 1, &span)) {
762
+ // Malformed declaration - skip to next semicolon to recover
763
+ if (ctx->check_malformed_declarations) {
764
+ // Extract property text for error message
765
+ const char *prop_text_end = pos;
766
+ trim_trailing(prop_start, &prop_text_end);
767
+ long prop_text_len = prop_text_end - prop_start;
768
+
769
+ const char *css = RSTRING_PTR(ctx->css_string);
770
+ long error_pos = prop_start - css;
771
+
772
+ if (prop_text_len == 0) {
773
+ // Build keyword args hash
774
+ VALUE kwargs = rb_hash_new();
775
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
776
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
777
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("malformed_declaration")));
778
+
779
+ VALUE msg_str = rb_str_new_cstr("Malformed declaration: missing property name");
780
+ VALUE argv[2] = {msg_str, kwargs};
781
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
782
+ rb_exc_raise(error);
783
+ } else {
784
+ // Limit property name to 200 chars in error message
785
+ int display_len = (prop_text_len > 200) ? 200 : (int)prop_text_len;
786
+ char error_msg[256];
787
+ snprintf(error_msg, sizeof(error_msg),
788
+ "Malformed declaration: missing colon after '%.*s'",
789
+ display_len, prop_start);
790
+
791
+ // Build keyword args hash
792
+ VALUE kwargs = rb_hash_new();
793
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
794
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
795
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("malformed_declaration")));
796
+
797
+ VALUE msg_str = rb_str_new_cstr(error_msg);
798
+ VALUE argv[2] = {msg_str, kwargs};
799
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
800
+ rb_exc_raise(error);
801
+ }
802
+ }
609
803
  while (pos < end && *pos != ';') pos++;
610
804
  if (pos < end) pos++; // Skip the semicolon
611
805
  continue;
612
806
  }
613
807
 
614
- const char *prop_end = pos;
615
- // Trim whitespace from property
616
- trim_trailing(prop_start, &prop_end);
617
- trim_leading(&prop_start, prop_end);
618
-
619
- pos++; // Skip colon
620
-
621
- // Skip whitespace after colon
622
- while (pos < end && IS_WHITESPACE(*pos)) {
623
- pos++;
624
- }
625
-
626
- // Find value (up to semicolon or end)
627
- // Must track paren depth to avoid breaking on semicolons inside url() or rgba()
628
- // Example: "url(data:image/svg+xml;base64,...); next-prop: ..."
629
- // ^val_start ^pos (at ; outside parens)
630
- const char *val_start = pos;
631
- int paren_depth = 0;
632
- while (pos < end) {
633
- if (*pos == '(') { // At: '('
634
- paren_depth++; // Depth: 1
635
- } else if (*pos == ')') { // At: ')'
636
- paren_depth--; // Depth: 0
637
- } else if (*pos == ';' && paren_depth == 0) { // At: ';' (outside parens)
638
- break; // Found terminating semicolon
639
- }
640
- pos++;
641
- }
642
- const char *val_end = pos;
643
-
644
- // Trim trailing whitespace from value
645
- trim_trailing(val_start, &val_end);
646
-
647
- // Check for !important
648
- int is_important = 0;
649
- if (val_end - val_start >= 10) { // strlen("!important") = 10
650
- const char *check = val_end - 10;
651
- while (check < val_end && IS_WHITESPACE(*check)) check++;
652
- if (check < val_end && *check == '!') {
653
- check++;
654
- while (check < val_end && IS_WHITESPACE(*check)) check++;
655
- // strncmp safely handles remaining length check
656
- if (check + 9 <= val_end && strncmp(check, "important", 9) == 0) {
657
- is_important = 1;
658
- const char *important_pos = check - 1;
659
- while (important_pos > val_start && (IS_WHITESPACE(*(important_pos-1)) || *(important_pos-1) == '!')) {
660
- important_pos--;
661
- }
662
- val_end = important_pos;
663
- }
664
- }
808
+ // Check for empty value
809
+ if (span.val_end <= span.val_start && ctx->check_empty_values) {
810
+ long prop_len = span.prop_end - span.prop_start;
811
+ const char *css = RSTRING_PTR(ctx->css_string);
812
+ long error_pos = span.val_start - css;
813
+
814
+ // Build error message
815
+ int display_len = (prop_len > 200) ? 200 : (int)prop_len;
816
+ char error_msg[256];
817
+ snprintf(error_msg, sizeof(error_msg),
818
+ "Empty value for property '%.*s'",
819
+ display_len, span.prop_start);
820
+
821
+ // Build keyword args hash
822
+ VALUE kwargs = rb_hash_new();
823
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
824
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
825
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("empty_value")));
826
+
827
+ // Create ParseError instance: ParseError.new(message, **kwargs)
828
+ VALUE msg_str = rb_str_new_cstr(error_msg);
829
+ VALUE argv[2] = {msg_str, kwargs};
830
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
831
+
832
+ // Raise the error
833
+ rb_exc_raise(error);
665
834
  }
666
835
 
667
- // Final trim
668
- trim_trailing(val_start, &val_end);
669
-
670
836
  // Skip if value is empty
671
- if (val_end > val_start) {
672
- long prop_len = prop_end - prop_start;
673
- long val_len = val_end - val_start;
837
+ if (span.val_end > span.val_start) {
838
+ long prop_len = span.prop_end - span.prop_start;
839
+ long val_len = span.val_end - span.val_start;
674
840
 
675
841
  // Check property name length
676
842
  if (prop_len > MAX_PROPERTY_NAME_LENGTH) {
@@ -687,18 +853,18 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
687
853
  }
688
854
 
689
855
  // Create property string - use UTF-8 to support custom properties with Unicode
690
- VALUE property = rb_utf8_str_new(prop_start, prop_len);
856
+ VALUE property = rb_utf8_str_new(span.prop_start, prop_len);
691
857
  // Custom properties (--foo) are case-sensitive and can contain Unicode
692
858
  // Regular properties are ASCII-only and case-insensitive
693
- if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
859
+ if (!(prop_len >= 2 && span.prop_start[0] == '-' && span.prop_start[1] == '-')) {
694
860
  // Regular property: force ASCII encoding and lowercase
695
861
  rb_enc_associate(property, rb_usascii_encoding());
696
862
  property = lowercase_property(property);
697
863
  }
698
- VALUE value = rb_utf8_str_new(val_start, val_len);
864
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
699
865
 
700
866
  // Convert relative URLs to absolute if enabled
701
- if (ctx && ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
867
+ if (ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
702
868
  value = convert_urls_in_value(value, ctx->base_uri, ctx->uri_resolver);
703
869
  }
704
870
 
@@ -706,13 +872,12 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
706
872
  VALUE decl = rb_struct_new(cDeclaration,
707
873
  property,
708
874
  value,
709
- is_important ? Qtrue : Qfalse
875
+ span.is_important ? Qtrue : Qfalse
710
876
  );
711
877
 
712
878
  rb_ary_push(declarations, decl);
713
879
  }
714
-
715
- if (pos < end && *pos == ';') pos++; // Skip semicolon if present
880
+ // parse_one_declaration already advanced pos past the ';' on success
716
881
  }
717
882
 
718
883
  return declarations;
@@ -801,6 +966,54 @@ static VALUE intern_media_query_safe(ParserContext *ctx, const char *query_str,
801
966
  return sym;
802
967
  }
803
968
 
969
+ /*
970
+ * Parse a single media query (already split on commas, if applicable, and
971
+ * trimmed of surrounding whitespace) into a type symbol and an optional
972
+ * conditions string.
973
+ *
974
+ * Examples:
975
+ * "(min-width: 500px)" -> type=:all, conditions="(min-width: 500px)"
976
+ * "screen" -> type=:screen, conditions=nil
977
+ * "screen and (min-width: 500)" -> type=:screen, conditions="(min-width: 500)"
978
+ *
979
+ * Shared by @media block parsing (parse_mixed_block's nested-@media case and
980
+ * parse_css_recursive's top-level case) and @import's trailing media query,
981
+ * so all three agree on stripping the "and" keyword between type and
982
+ * conditions.
983
+ */
984
+ static void parse_single_media_query(const char *query_start, const char *query_end,
985
+ VALUE *media_type_out, VALUE *media_conditions_out) {
986
+ const char *mq_ptr = query_start;
987
+ VALUE media_type;
988
+ VALUE media_conditions = Qnil;
989
+
990
+ if (*mq_ptr == '(') {
991
+ // Starts with '(' - just conditions, type defaults to :all
992
+ media_type = ID2SYM(rb_intern("all"));
993
+ media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
994
+ } else {
995
+ // Extract media type (first word)
996
+ const char *type_start = mq_ptr;
997
+ while (mq_ptr < query_end && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
998
+ VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
999
+ media_type = ID2SYM(rb_intern_str(type_str));
1000
+
1001
+ // Skip whitespace and "and" keyword if present
1002
+ while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1003
+ if (mq_ptr + 3 <= query_end && strncmp(mq_ptr, "and", 3) == 0) {
1004
+ mq_ptr += 3;
1005
+ while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1006
+ }
1007
+
1008
+ if (mq_ptr < query_end) {
1009
+ media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1010
+ }
1011
+ }
1012
+
1013
+ *media_type_out = media_type;
1014
+ *media_conditions_out = media_conditions;
1015
+ }
1016
+
804
1017
  /*
805
1018
  * Parse mixed declarations and nested selectors from a block
806
1019
  * Used when a CSS rule block contains both declarations and nested rules
@@ -856,31 +1069,9 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
856
1069
  trim_trailing(media_query_start, &media_query_end_trimmed);
857
1070
 
858
1071
  // Parse media query and create MediaQuery object
859
- const char *mq_ptr = media_query_start;
860
1072
  VALUE media_type;
861
- VALUE media_conditions = Qnil;
862
-
863
- if (*mq_ptr == '(') {
864
- // Starts with '(' - just conditions, type defaults to :all
865
- media_type = ID2SYM(rb_intern("all"));
866
- media_conditions = rb_utf8_str_new(mq_ptr, media_query_end_trimmed - mq_ptr);
867
- } else {
868
- // Extract media type (first word)
869
- const char *type_start = mq_ptr;
870
- while (mq_ptr < media_query_end_trimmed && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
871
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
872
- media_type = ID2SYM(rb_intern_str(type_str));
873
-
874
- // Skip "and" keyword if present
875
- while (mq_ptr < media_query_end_trimmed && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
876
- if (mq_ptr + 3 <= media_query_end_trimmed && strncmp(mq_ptr, "and", 3) == 0) {
877
- mq_ptr += 3;
878
- while (mq_ptr < media_query_end_trimmed && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
879
- }
880
- if (mq_ptr < media_query_end_trimmed) {
881
- media_conditions = rb_utf8_str_new(mq_ptr, media_query_end_trimmed - mq_ptr);
882
- }
883
- }
1073
+ VALUE media_conditions;
1074
+ parse_single_media_query(media_query_start, media_query_end_trimmed, &media_type, &media_conditions);
884
1075
 
885
1076
  // Create MediaQuery object
886
1077
  VALUE media_query = rb_struct_new(cMediaQuery,
@@ -896,7 +1087,7 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
896
1087
 
897
1088
  // Find matching closing brace
898
1089
  const char *media_block_start = p;
899
- const char *media_block_end = find_matching_brace(p, end);
1090
+ const char *media_block_end = find_matching_brace_strict(p, end, ctx->check_unclosed_blocks);
900
1091
  p = media_block_end;
901
1092
 
902
1093
  if (p < end) p++; // Skip }
@@ -909,7 +1100,7 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
909
1100
 
910
1101
  // This should never happen - parent_media_query_id should always be valid
911
1102
  if (NIL_P(parent_mq)) {
912
- rb_raise(eParserError,
1103
+ rb_raise(eParseError,
913
1104
  "Invalid parent_media_query_id: %d (not found in media_queries array)",
914
1105
  parent_media_query_id);
915
1106
  }
@@ -1022,7 +1213,7 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1022
1213
  // Example: "& .child { font: 14px; }"
1023
1214
  // ^nested_block_start ^nested_block_end (at })
1024
1215
  const char *nested_block_start = p;
1025
- const char *nested_block_end = find_matching_brace(p, end);
1216
+ const char *nested_block_end = find_matching_brace_strict(p, end, ctx->check_unclosed_blocks);
1026
1217
  p = nested_block_end;
1027
1218
 
1028
1219
  if (p < end) p++; // Skip }
@@ -1095,49 +1286,18 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1095
1286
  }
1096
1287
 
1097
1288
  // This is a declaration - parse it
1098
- const char *prop_start = p;
1099
- while (p < end && *p != ':' && *p != ';' && *p != '{') p++;
1100
- if (p >= end || *p != ':') {
1289
+ struct declaration_span span;
1290
+ if (!parse_one_declaration(&p, end, 1, &span)) {
1101
1291
  // Malformed - skip to semicolon
1102
1292
  while (p < end && *p != ';') p++;
1103
1293
  if (p < end) p++;
1104
1294
  continue;
1105
1295
  }
1106
1296
 
1107
- const char *prop_end = p;
1108
- trim_trailing(prop_start, &prop_end);
1109
-
1110
- p++; // Skip :
1111
- trim_leading(&p, end);
1112
-
1113
- const char *val_start = p;
1114
- int important = 0;
1115
-
1116
- // Find end of value (semicolon or closing brace or end)
1117
- while (p < end && *p != ';' && *p != '}') p++;
1118
- const char *val_end = p;
1119
-
1120
- // Check for !important
1121
- const char *important_check = val_end - 10; // " !important"
1122
- if (important_check >= val_start) {
1123
- trim_trailing(val_start, &val_end);
1124
- if (val_end - val_start >= 10) {
1125
- if (strncmp(val_end - 10, "!important", 10) == 0) {
1126
- important = 1;
1127
- val_end -= 10;
1128
- trim_trailing(val_start, &val_end);
1129
- }
1130
- }
1131
- } else {
1132
- trim_trailing(val_start, &val_end);
1133
- }
1134
-
1135
- if (p < end && *p == ';') p++;
1136
-
1137
1297
  // Create declaration
1138
- if (prop_end > prop_start && val_end > val_start) {
1139
- long prop_len = prop_end - prop_start;
1140
- long val_len = val_end - val_start;
1298
+ if (span.prop_end > span.prop_start && span.val_end > span.val_start) {
1299
+ long prop_len = span.prop_end - span.prop_start;
1300
+ long val_len = span.val_end - span.val_start;
1141
1301
 
1142
1302
  // Check property name length
1143
1303
  if (prop_len > MAX_PROPERTY_NAME_LENGTH) {
@@ -1154,15 +1314,15 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1154
1314
  }
1155
1315
 
1156
1316
  // Create property string - use UTF-8 to support custom properties with Unicode
1157
- VALUE property = rb_utf8_str_new(prop_start, prop_len);
1317
+ VALUE property = rb_utf8_str_new(span.prop_start, prop_len);
1158
1318
  // Custom properties (--foo) are case-sensitive and can contain Unicode
1159
1319
  // Regular properties are ASCII-only and case-insensitive
1160
- if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
1320
+ if (!(prop_len >= 2 && span.prop_start[0] == '-' && span.prop_start[1] == '-')) {
1161
1321
  // Regular property: force ASCII encoding and lowercase
1162
1322
  rb_enc_associate(property, rb_usascii_encoding());
1163
1323
  property = lowercase_property(property);
1164
1324
  }
1165
- VALUE value = rb_utf8_str_new(val_start, val_len);
1325
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
1166
1326
 
1167
1327
  // Convert relative URLs to absolute if enabled
1168
1328
  if (ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
@@ -1172,7 +1332,7 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1172
1332
  VALUE decl = rb_struct_new(cDeclaration,
1173
1333
  property,
1174
1334
  value,
1175
- important ? Qtrue : Qfalse
1335
+ span.is_important ? Qtrue : Qfalse
1176
1336
  );
1177
1337
 
1178
1338
  rb_ary_push(declarations, decl);
@@ -1283,29 +1443,9 @@ static void parse_import_statement(ParserContext *ctx, const char **p_ptr, const
1283
1443
 
1284
1444
  if (query_start < query_end) {
1285
1445
  // Parse this individual media query
1286
- const char *mq_ptr = query_start;
1287
1446
  VALUE media_type;
1288
- VALUE media_conditions = Qnil;
1289
-
1290
- if (*mq_ptr == '(') {
1291
- // Starts with '(' - just conditions, type defaults to :all
1292
- media_type = ID2SYM(rb_intern("all"));
1293
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1294
- } else {
1295
- // Extract media type (first word)
1296
- const char *type_start = mq_ptr;
1297
- while (mq_ptr < query_end && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
1298
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
1299
- media_type = ID2SYM(rb_intern_str(type_str));
1300
-
1301
- // Skip whitespace
1302
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1303
-
1304
- // Check if there are conditions (rest of string)
1305
- if (mq_ptr < query_end) {
1306
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1307
- }
1308
- }
1447
+ VALUE media_conditions;
1448
+ parse_single_media_query(query_start, query_end, &media_type, &media_conditions);
1309
1449
 
1310
1450
  // Create MediaQuery struct
1311
1451
  VALUE media_query = rb_struct_new(cMediaQuery,
@@ -1362,6 +1502,665 @@ static void parse_import_statement(ParserContext *ctx, const char **p_ptr, const
1362
1502
  RB_GC_GUARD(import_stmt);
1363
1503
  }
1364
1504
 
1505
+ /*
1506
+ * Handle an @media at-rule found at brace_depth 0. Called with *p_ptr
1507
+ * pointing at the '@' of "@media"; advances *p_ptr past the entire
1508
+ * "@media ... { ... }" construct (including its closing '}', if found).
1509
+ */
1510
+ static void handle_media_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1511
+ VALUE parent_media_sym, int parent_media_query_id) {
1512
+ const char *p = *p_ptr;
1513
+ p += 6; // Skip "@media"
1514
+
1515
+ // Skip whitespace
1516
+ while (p < pe && IS_WHITESPACE(*p)) p++;
1517
+
1518
+ // Find media query (up to opening brace)
1519
+ const char *mq_start = p;
1520
+ while (p < pe && *p != '{') p++;
1521
+ const char *mq_end = p;
1522
+
1523
+ // Trim
1524
+ trim_trailing(mq_start, &mq_end);
1525
+
1526
+ // Check for empty media query
1527
+ if (mq_end <= mq_start) {
1528
+ if (ctx->check_malformed_at_rules) {
1529
+ raise_parse_error_at(ctx, mq_start, "Malformed @media: missing media query", "malformed_at_rule");
1530
+ }
1531
+
1532
+ // Empty media query with check disabled - skip @media wrapper and parse contents as regular rules
1533
+ if (p >= pe || *p != '{') {
1534
+ *p_ptr = p;
1535
+ return; // Malformed structure
1536
+ }
1537
+ p++; // Skip opening {
1538
+ const char *block_start = p;
1539
+ const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1540
+ p = block_end;
1541
+
1542
+ // Parse block contents with NO media query context
1543
+ ctx->depth++;
1544
+ parse_css_recursive(ctx, block_start, block_end, parent_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, parent_media_query_id);
1545
+ ctx->depth--;
1546
+
1547
+ if (p < pe && *p == '}') p++;
1548
+ *p_ptr = p;
1549
+ return;
1550
+ }
1551
+
1552
+ if (p >= pe || *p != '{') {
1553
+ *p_ptr = p;
1554
+ return; // Malformed
1555
+ }
1556
+
1557
+ // Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1558
+ // Per W3C spec, comma acts as logical OR - each query is independent
1559
+ VALUE media_query_ids = rb_ary_new();
1560
+
1561
+ const char *query_start = mq_start;
1562
+ for (const char *p_comma = mq_start; p_comma <= mq_end; p_comma++) {
1563
+ if (p_comma == mq_end || *p_comma == ',') {
1564
+ const char *query_end = p_comma;
1565
+
1566
+ // Trim whitespace from this query
1567
+ while (query_start < query_end && IS_WHITESPACE(*query_start)) query_start++;
1568
+ while (query_end > query_start && IS_WHITESPACE(*(query_end - 1))) query_end--;
1569
+
1570
+ if (query_start < query_end) {
1571
+ // Parse this individual media query
1572
+ VALUE media_type;
1573
+ VALUE media_conditions;
1574
+ parse_single_media_query(query_start, query_end, &media_type, &media_conditions);
1575
+
1576
+ // Create MediaQuery object for this query
1577
+ VALUE media_query = rb_struct_new(cMediaQuery,
1578
+ INT2FIX(ctx->media_query_id_counter),
1579
+ media_type,
1580
+ media_conditions
1581
+ );
1582
+ rb_ary_push(ctx->media_queries, media_query);
1583
+ rb_ary_push(media_query_ids, INT2FIX(ctx->media_query_id_counter));
1584
+ ctx->media_query_id_counter++;
1585
+ }
1586
+
1587
+ // Move to start of next query
1588
+ query_start = p_comma + 1;
1589
+ }
1590
+ }
1591
+
1592
+ // If multiple queries, track them as a list for serialization
1593
+ int media_query_list_id = -1;
1594
+ if (RARRAY_LEN(media_query_ids) > 1) {
1595
+ media_query_list_id = ctx->next_media_query_list_id;
1596
+ rb_hash_aset(ctx->media_query_lists, INT2FIX(media_query_list_id), media_query_ids);
1597
+ ctx->next_media_query_list_id++;
1598
+ }
1599
+
1600
+ // Use first query ID as the primary one for rules in this block
1601
+ int current_media_query_id = FIX2INT(rb_ary_entry(media_query_ids, 0));
1602
+
1603
+ // Handle nested @media by combining with parent
1604
+ if (parent_media_query_id >= 0) {
1605
+ VALUE parent_mq = rb_ary_entry(ctx->media_queries, parent_media_query_id);
1606
+ VALUE parent_type = rb_struct_aref(parent_mq, INT2FIX(1)); // type field
1607
+ VALUE parent_conditions = rb_struct_aref(parent_mq, INT2FIX(2)); // conditions field
1608
+
1609
+ // Get child media query (first one in the list)
1610
+ VALUE child_mq = rb_ary_entry(ctx->media_queries, current_media_query_id);
1611
+ VALUE child_conditions = rb_struct_aref(child_mq, INT2FIX(2)); // conditions field
1612
+
1613
+ // Combined type is parent's type (outermost wins, child type ignored)
1614
+ VALUE combined_type = parent_type;
1615
+ VALUE combined_conditions;
1616
+
1617
+ if (!NIL_P(parent_conditions) && !NIL_P(child_conditions)) {
1618
+ combined_conditions = rb_sprintf("%"PRIsVALUE" and %"PRIsVALUE, parent_conditions, child_conditions);
1619
+ } else if (!NIL_P(parent_conditions)) {
1620
+ combined_conditions = parent_conditions;
1621
+ } else {
1622
+ combined_conditions = child_conditions;
1623
+ }
1624
+
1625
+ VALUE combined_mq = rb_struct_new(cMediaQuery,
1626
+ INT2FIX(ctx->media_query_id_counter),
1627
+ combined_type,
1628
+ combined_conditions
1629
+ );
1630
+ rb_ary_push(ctx->media_queries, combined_mq);
1631
+ current_media_query_id = ctx->media_query_id_counter;
1632
+ ctx->media_query_id_counter++;
1633
+ }
1634
+
1635
+ // For backwards compat, also create symbol (will be removed later)
1636
+ VALUE child_media_sym = intern_media_query_safe(ctx, mq_start, mq_end - mq_start);
1637
+ VALUE combined_media_sym = combine_media_queries(parent_media_sym, child_media_sym);
1638
+
1639
+ p++; // Skip opening {
1640
+
1641
+ // Find matching closing brace
1642
+ const char *block_start = p;
1643
+ const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1644
+ p = block_end;
1645
+
1646
+ // Recursively parse @media block with new media query context
1647
+ ctx->depth++;
1648
+ parse_css_recursive(ctx, block_start, block_end, combined_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, current_media_query_id);
1649
+ ctx->depth--;
1650
+
1651
+ if (p < pe && *p == '}') p++;
1652
+ *p_ptr = p;
1653
+ }
1654
+
1655
+ /*
1656
+ * Handle a conditional-group at-rule (@supports/@layer/@container/@scope)
1657
+ * found at brace_depth 0. Behaves like @media but doesn't affect media
1658
+ * context, so it recurses with the same parent_media_sym/parent_selector/
1659
+ * parent_rule_id it was called with. Called with *p_ptr pointing at the
1660
+ * '@'; at_start/at_name_end/at_name_len describe the already-scanned
1661
+ * at-rule name. Advances *p_ptr past the entire construct (including its
1662
+ * closing '}', if found).
1663
+ */
1664
+ static void handle_conditional_group_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1665
+ const char *at_start, const char *at_name_end, long at_name_len,
1666
+ VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id,
1667
+ int parent_media_query_id) {
1668
+ // Check if this rule requires a condition
1669
+ BOOLEAN requires_condition =
1670
+ (at_name_len == 8 && strncmp(at_start, "supports", 8) == 0) ||
1671
+ (at_name_len == 9 && strncmp(at_start, "container", 9) == 0);
1672
+
1673
+ // Extract condition (between at-rule name and opening brace)
1674
+ const char *cond_start = at_name_end;
1675
+ while (cond_start < pe && IS_WHITESPACE(*cond_start)) cond_start++;
1676
+
1677
+ // Skip to opening brace
1678
+ const char *p = at_name_end;
1679
+ while (p < pe && *p != '{') p++;
1680
+
1681
+ if (p >= pe || *p != '{') {
1682
+ *p_ptr = p;
1683
+ return; // Malformed
1684
+ }
1685
+
1686
+ // Trim condition
1687
+ const char *cond_end = p;
1688
+ while (cond_end > cond_start && IS_WHITESPACE(*(cond_end - 1))) cond_end--;
1689
+
1690
+ // Check for missing condition
1691
+ if (requires_condition && cond_end <= cond_start && ctx->check_malformed_at_rules) {
1692
+ char error_msg[100];
1693
+ snprintf(error_msg, sizeof(error_msg), "Malformed @%.*s: missing condition", (int)at_name_len, at_start);
1694
+ raise_parse_error_at(ctx, at_start - 1, error_msg, "malformed_at_rule");
1695
+ }
1696
+
1697
+ p++; // Skip opening {
1698
+
1699
+ // Find matching closing brace
1700
+ const char *block_start = p;
1701
+ const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1702
+ p = block_end;
1703
+
1704
+ // Recursively parse block content (preserve parent media context)
1705
+ ctx->depth++;
1706
+ parse_css_recursive(ctx, block_start, block_end, parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
1707
+ ctx->depth--;
1708
+
1709
+ if (p < pe && *p == '}') p++;
1710
+ *p_ptr = p;
1711
+ }
1712
+
1713
+ /*
1714
+ * Handle an @keyframes (or -webkit-/-moz- prefixed) at-rule found at
1715
+ * brace_depth 0. Called with *p_ptr pointing at the '@'; at_name_end marks
1716
+ * the end of the already-scanned at-rule name. Advances *p_ptr past the
1717
+ * entire construct (including its closing '}', if found).
1718
+ */
1719
+ static void handle_keyframes_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1720
+ const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id) {
1721
+ // Build full selector string: "@keyframes fade"
1722
+ const char *selector_start = *p_ptr; // Points to '@'
1723
+ const char *p = at_name_end;
1724
+ while (p < pe && *p != '{') p++;
1725
+
1726
+ if (p >= pe || *p != '{') {
1727
+ *p_ptr = p;
1728
+ return; // Malformed
1729
+ }
1730
+
1731
+ const char *selector_end = p;
1732
+ while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1733
+ selector_end--;
1734
+ }
1735
+ VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1736
+
1737
+ p++; // Skip opening {
1738
+
1739
+ // Find matching closing brace
1740
+ const char *block_start = p;
1741
+ const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1742
+ p = block_end;
1743
+
1744
+ // Parse keyframe blocks as rules (from/to/0%/50% etc). Unlike @media/@supports,
1745
+ // which thread ctx straight through, these aren't real page selectors and must
1746
+ // NOT land in ctx->rules_array — they need their own array to become this
1747
+ // AtRule's `content` below, so parse into an isolated child context instead.
1748
+ ParserContext nested_ctx = init_child_context(ctx);
1749
+ parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID);
1750
+
1751
+ // Get rule ID and increment
1752
+ int rule_id = ctx->rule_id_counter++;
1753
+
1754
+ // Create AtRule with nested rules
1755
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1756
+ VALUE at_rule = rb_struct_new(cAtRule,
1757
+ INT2FIX(rule_id),
1758
+ selector,
1759
+ nested_ctx.rules_array, // Array of Rule (keyframe blocks)
1760
+ Qnil, // specificity
1761
+ media_query_id_val // media_query_id from parent context
1762
+ );
1763
+
1764
+ // Add to rules array
1765
+ rb_ary_push(ctx->rules_array, at_rule);
1766
+
1767
+ // Add to media index if in media query
1768
+ if (!NIL_P(parent_media_sym)) {
1769
+ VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1770
+ if (NIL_P(rule_ids)) {
1771
+ rule_ids = rb_ary_new();
1772
+ rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1773
+ }
1774
+ rb_ary_push(rule_ids, INT2FIX(rule_id));
1775
+ }
1776
+
1777
+ if (p < pe && *p == '}') p++;
1778
+ *p_ptr = p;
1779
+ }
1780
+
1781
+ /*
1782
+ * Handle an @font-face at-rule found at brace_depth 0. Called with *p_ptr
1783
+ * pointing at the '@'; at_name_end marks the end of the already-scanned
1784
+ * at-rule name. Advances *p_ptr past the entire construct (including its
1785
+ * closing '}', if found).
1786
+ */
1787
+ static void handle_font_face_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1788
+ const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id) {
1789
+ // Build selector string: "@font-face"
1790
+ const char *selector_start = *p_ptr; // Points to '@'
1791
+ const char *p = at_name_end;
1792
+ while (p < pe && *p != '{') p++;
1793
+
1794
+ if (p >= pe || *p != '{') {
1795
+ *p_ptr = p;
1796
+ return; // Malformed
1797
+ }
1798
+
1799
+ const char *selector_end = p;
1800
+ while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1801
+ selector_end--;
1802
+ }
1803
+ VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1804
+
1805
+ p++; // Skip opening {
1806
+
1807
+ // Find matching closing brace
1808
+ const char *decl_start = p;
1809
+ const char *decl_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1810
+ p = decl_end;
1811
+
1812
+ // Parse declarations
1813
+ VALUE declarations = parse_declarations(decl_start, decl_end, ctx);
1814
+
1815
+ // Get rule ID and increment
1816
+ int rule_id = ctx->rule_id_counter++;
1817
+
1818
+ // Create AtRule with declarations
1819
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1820
+ VALUE at_rule = rb_struct_new(cAtRule,
1821
+ INT2FIX(rule_id),
1822
+ selector,
1823
+ declarations, // Array of Declaration
1824
+ Qnil, // specificity
1825
+ media_query_id_val // media_query_id from parent context
1826
+ );
1827
+
1828
+ // Add to rules array
1829
+ rb_ary_push(ctx->rules_array, at_rule);
1830
+
1831
+ // Add to media index if in media query
1832
+ if (!NIL_P(parent_media_sym)) {
1833
+ VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1834
+ if (NIL_P(rule_ids)) {
1835
+ rule_ids = rb_ary_new();
1836
+ rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1837
+ }
1838
+ rb_ary_push(rule_ids, INT2FIX(rule_id));
1839
+ }
1840
+
1841
+ if (p < pe && *p == '}') p++;
1842
+ *p_ptr = p;
1843
+ }
1844
+
1845
+ /*
1846
+ * Finish a complete top-level CSS rule block just found at its closing '}'
1847
+ * (p points at that '}'; [selector_start, decl_start) is the selector text
1848
+ * and [decl_start, p) is the body). Determines whether the body has nested
1849
+ * selectors and either parses it as pure declarations (fast path, possibly
1850
+ * split across comma-separated selectors) or recurses per comma-separated
1851
+ * selector via parse_mixed_block (nested path). Does not modify p or reset
1852
+ * selector_start/decl_start - the caller does that once this returns.
1853
+ */
1854
+ static void finish_rule_block(ParserContext *ctx, const char *selector_start, const char *decl_start, const char *p,
1855
+ VALUE parent_selector, VALUE parent_rule_id, VALUE parent_media_sym, int parent_media_query_id) {
1856
+ // We've found a complete CSS rule block - now determine if it has nesting
1857
+ // Example: .parent { color: red; & .child { font-size: 14px; } }
1858
+ // ^selector_start ^decl_start ^p (at })
1859
+ BOOLEAN has_nesting = has_nested_selectors(decl_start, p);
1860
+
1861
+ // Get selector string
1862
+ const char *sel_end = decl_start - 1;
1863
+ while (sel_end > selector_start && IS_WHITESPACE(*(sel_end - 1))) {
1864
+ sel_end--;
1865
+ }
1866
+
1867
+ // Check for empty selector
1868
+ if (ctx->check_invalid_selectors && sel_end <= selector_start) {
1869
+ const char *css = RSTRING_PTR(ctx->css_string);
1870
+ long error_pos = selector_start - css;
1871
+
1872
+ // Build keyword args hash
1873
+ VALUE kwargs = rb_hash_new();
1874
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
1875
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
1876
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("invalid_selector")));
1877
+
1878
+ VALUE msg_str = rb_str_new_cstr("Invalid selector: empty selector");
1879
+ VALUE argv[2] = {msg_str, kwargs};
1880
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
1881
+ rb_exc_raise(error);
1882
+ }
1883
+
1884
+ if (!has_nesting) {
1885
+ // FAST PATH: No nesting - parse as pure declarations
1886
+ VALUE declarations = parse_declarations(decl_start, p, ctx);
1887
+
1888
+ // Split on commas to handle multi-selector rules
1889
+ // Example: ".a, .b, .c { color: red; }" creates 3 separate rules
1890
+ // ^selector_start ^sel_end
1891
+ // ^seg_start=seg (scanning for commas)
1892
+
1893
+ // Count selectors for selector list tracking
1894
+ int selector_count = 1;
1895
+ if (ctx->selector_lists_enabled) {
1896
+ const char *count_ptr = selector_start;
1897
+ while (count_ptr < sel_end) {
1898
+ if (*count_ptr == ',') {
1899
+ selector_count++;
1900
+ }
1901
+ count_ptr++;
1902
+ }
1903
+ }
1904
+
1905
+ // Create selector list if enabled and multiple selectors
1906
+ int list_id = -1;
1907
+ VALUE rule_ids_array = Qnil;
1908
+ if (ctx->selector_lists_enabled && selector_count > 1) {
1909
+ list_id = ctx->next_selector_list_id++;
1910
+ rule_ids_array = rb_ary_new();
1911
+ rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
1912
+ }
1913
+
1914
+ const char *seg_start = selector_start;
1915
+ const char *seg = selector_start;
1916
+
1917
+ while (seg <= sel_end) {
1918
+ if (seg == sel_end || *seg == ',') { // At: ',' or end
1919
+ // Trim segment
1920
+ while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
1921
+ seg_start++;
1922
+ }
1923
+
1924
+ const char *seg_end_ptr = seg;
1925
+ while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
1926
+ seg_end_ptr--;
1927
+ }
1928
+
1929
+ if (seg_end_ptr > seg_start) {
1930
+ // Check for invalid selectors
1931
+ if (ctx->check_invalid_selectors) {
1932
+ // Check if selector starts with combinator
1933
+ char first_char = *seg_start;
1934
+ if (first_char == '>' || first_char == '+' || first_char == '~') {
1935
+ const char *css = RSTRING_PTR(ctx->css_string);
1936
+ long error_pos = seg_start - css;
1937
+
1938
+ char error_msg[256];
1939
+ snprintf(error_msg, sizeof(error_msg),
1940
+ "Invalid selector: selector cannot start with combinator '%c'",
1941
+ first_char);
1942
+
1943
+ // Build keyword args hash
1944
+ VALUE kwargs = rb_hash_new();
1945
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
1946
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
1947
+ rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("invalid_selector")));
1948
+
1949
+ VALUE msg_str = rb_str_new_cstr(error_msg);
1950
+ VALUE argv[2] = {msg_str, kwargs};
1951
+ VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
1952
+ rb_exc_raise(error);
1953
+ }
1954
+ }
1955
+
1956
+ // Check for invalid selector syntax (whitelist validation)
1957
+ if (ctx->check_invalid_selector_syntax && !is_valid_selector(seg_start, seg_end_ptr)) {
1958
+ raise_parse_error_at(ctx, seg_start, "Invalid selector syntax: selector contains invalid characters", "invalid_selector_syntax");
1959
+ }
1960
+
1961
+ VALUE selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
1962
+
1963
+ // Resolve against parent if nested
1964
+ VALUE resolved_selector;
1965
+ VALUE nesting_style_val;
1966
+ VALUE parent_id_val;
1967
+
1968
+ if (!NIL_P(parent_selector)) {
1969
+ // This is a nested rule - resolve selector
1970
+ VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(selector), RSTRING_LEN(selector));
1971
+ resolved_selector = rb_ary_entry(result, 0);
1972
+ nesting_style_val = rb_ary_entry(result, 1);
1973
+ parent_id_val = parent_rule_id;
1974
+ } else {
1975
+ // Top-level rule
1976
+ resolved_selector = selector;
1977
+ nesting_style_val = Qnil;
1978
+ parent_id_val = Qnil;
1979
+ }
1980
+
1981
+ // Get rule ID and increment
1982
+ int rule_id = ctx->rule_id_counter++;
1983
+
1984
+ // Determine selector_list_id value
1985
+ VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
1986
+
1987
+ // Deep copy declarations for selector lists to avoid shared state
1988
+ // (principle of least surprise - modifying one rule shouldn't affect others)
1989
+ VALUE rule_declarations;
1990
+ if (list_id >= 0) {
1991
+ // Deep copy: both array and Declaration structs inside
1992
+ long decl_count = RARRAY_LEN(declarations);
1993
+ rule_declarations = rb_ary_new_capa(decl_count);
1994
+ for (long k = 0; k < decl_count; k++) {
1995
+ VALUE decl = rb_ary_entry(declarations, k);
1996
+ VALUE new_decl = rb_struct_new(cDeclaration,
1997
+ rb_struct_aref(decl, INT2FIX(DECL_PROPERTY)),
1998
+ rb_struct_aref(decl, INT2FIX(DECL_VALUE)),
1999
+ rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT))
2000
+ );
2001
+ rb_ary_push(rule_declarations, new_decl);
2002
+ }
2003
+ } else {
2004
+ rule_declarations = rb_ary_dup(declarations);
2005
+ }
2006
+
2007
+ // Create Rule
2008
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2009
+ VALUE rule = rb_struct_new(cRule,
2010
+ INT2FIX(rule_id),
2011
+ resolved_selector,
2012
+ rule_declarations,
2013
+ Qnil, // specificity
2014
+ parent_id_val,
2015
+ nesting_style_val,
2016
+ selector_list_id_val,
2017
+ media_query_id_val // media_query_id from parent context
2018
+ );
2019
+
2020
+ // Track rule in selector list if applicable
2021
+ if (list_id >= 0) {
2022
+ rb_ary_push(rule_ids_array, INT2FIX(rule_id));
2023
+ }
2024
+
2025
+ // Mark that we have nesting (only set once)
2026
+ if (!ctx->has_nesting && !NIL_P(parent_id_val)) {
2027
+ ctx->has_nesting = 1;
2028
+ }
2029
+
2030
+ rb_ary_push(ctx->rules_array, rule);
2031
+
2032
+ // Update media index
2033
+ update_media_index(ctx, parent_media_sym, rule_id);
2034
+ } else if (ctx->check_invalid_selector_syntax && selector_count > 1) {
2035
+ // Empty selector in comma-separated list (e.g., "h1, , h3")
2036
+ raise_parse_error_at(ctx, seg_start, "Invalid selector syntax: empty selector in comma-separated list", "invalid_selector_syntax");
2037
+ }
2038
+
2039
+ seg_start = seg + 1;
2040
+ }
2041
+ seg++;
2042
+ }
2043
+ } else {
2044
+ // NESTED PATH: Parse mixed declarations + nested rules
2045
+ // For each comma-separated parent selector, parse the block with that parent
2046
+ //
2047
+ // Example: ".a, .b { color: red; & .child { font: 14px; } }"
2048
+ // ^selector_start ^sel_end
2049
+ // Creates:
2050
+ // - .a with declarations [color: red]
2051
+ // - .a .child with declarations [font: 14px]
2052
+ // - .b with declarations [color: red]
2053
+ // - .b .child with declarations [font: 14px]
2054
+
2055
+ // Count selectors for selector list tracking
2056
+ int selector_count = 1;
2057
+ if (ctx->selector_lists_enabled) {
2058
+ const char *count_ptr = selector_start;
2059
+ while (count_ptr < sel_end) {
2060
+ if (*count_ptr == ',') {
2061
+ selector_count++;
2062
+ }
2063
+ count_ptr++;
2064
+ }
2065
+ }
2066
+
2067
+ // Create selector list if enabled and multiple selectors
2068
+ int list_id = -1;
2069
+ VALUE rule_ids_array = Qnil;
2070
+ if (ctx->selector_lists_enabled && selector_count > 1) {
2071
+ list_id = ctx->next_selector_list_id++;
2072
+ rule_ids_array = rb_ary_new();
2073
+ rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
2074
+ }
2075
+
2076
+ const char *seg_start = selector_start;
2077
+ const char *seg = selector_start;
2078
+
2079
+ while (seg <= sel_end) {
2080
+ if (seg == sel_end || *seg == ',') { // At: ',' or end
2081
+ // Trim segment
2082
+ while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
2083
+ seg_start++;
2084
+ }
2085
+
2086
+ const char *seg_end_ptr = seg;
2087
+ while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
2088
+ seg_end_ptr--;
2089
+ }
2090
+
2091
+ if (seg_end_ptr > seg_start) {
2092
+ VALUE current_selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
2093
+
2094
+ // Resolve against parent if we're already nested
2095
+ VALUE resolved_current;
2096
+ VALUE current_nesting_style;
2097
+ VALUE current_parent_id;
2098
+
2099
+ if (!NIL_P(parent_selector)) {
2100
+ VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(current_selector), RSTRING_LEN(current_selector));
2101
+ resolved_current = rb_ary_entry(result, 0);
2102
+ current_nesting_style = rb_ary_entry(result, 1);
2103
+ current_parent_id = parent_rule_id;
2104
+ } else {
2105
+ resolved_current = current_selector;
2106
+ current_nesting_style = Qnil;
2107
+ current_parent_id = Qnil;
2108
+ }
2109
+
2110
+ // Get rule ID for current selector (increment to reserve it)
2111
+ int current_rule_id = ctx->rule_id_counter++;
2112
+
2113
+ // Reserve parent's position in rules array with placeholder
2114
+ // This ensures parent comes before nested rules in array order (per W3C spec)
2115
+ long parent_position = RARRAY_LEN(ctx->rules_array);
2116
+ rb_ary_push(ctx->rules_array, Qnil);
2117
+
2118
+ // Parse mixed block (declarations + nested selectors)
2119
+ // Nested rules will be added AFTER the placeholder
2120
+ ctx->depth++;
2121
+ VALUE parent_declarations = parse_mixed_block(ctx, decl_start, p,
2122
+ resolved_current, INT2FIX(current_rule_id), parent_media_sym, parent_media_query_id);
2123
+ ctx->depth--;
2124
+
2125
+ // Determine selector_list_id value
2126
+ VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
2127
+
2128
+ // Create parent rule and replace placeholder
2129
+ // Always create the rule (even if empty) to avoid edge cases
2130
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2131
+ VALUE rule = rb_struct_new(cRule,
2132
+ INT2FIX(current_rule_id),
2133
+ resolved_current,
2134
+ parent_declarations,
2135
+ Qnil, // specificity
2136
+ current_parent_id,
2137
+ current_nesting_style,
2138
+ selector_list_id_val,
2139
+ media_query_id_val // media_query_id from parent context
2140
+ );
2141
+
2142
+ // Track rule in selector list if applicable
2143
+ if (list_id >= 0) {
2144
+ rb_ary_push(rule_ids_array, INT2FIX(current_rule_id));
2145
+ }
2146
+
2147
+ // Mark that we have nesting (only set once)
2148
+ if (!ctx->has_nesting && !NIL_P(current_parent_id)) {
2149
+ ctx->has_nesting = 1;
2150
+ }
2151
+
2152
+ // Replace placeholder with actual rule - just pointer assignment, fast!
2153
+ rb_ary_store(ctx->rules_array, parent_position, rule);
2154
+ update_media_index(ctx, parent_media_sym, current_rule_id);
2155
+ }
2156
+
2157
+ seg_start = seg + 1;
2158
+ }
2159
+ seg++;
2160
+ }
2161
+ }
2162
+ }
2163
+
1365
2164
  /*
1366
2165
  * Parse CSS recursively with media query context and optional parent selector for nesting
1367
2166
  *
@@ -1419,142 +2218,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1419
2218
  // Check for @media at-rule (only at depth 0)
1420
2219
  if (RB_UNLIKELY(brace_depth == 0 && p + 6 < pe && *p == '@' &&
1421
2220
  strncmp(p + 1, "media", 5) == 0 && IS_WHITESPACE(p[6]))) {
1422
- p += 6; // Skip "@media"
1423
-
1424
- // Skip whitespace
1425
- while (p < pe && IS_WHITESPACE(*p)) p++;
1426
-
1427
- // Find media query (up to opening brace)
1428
- const char *mq_start = p;
1429
- while (p < pe && *p != '{') p++;
1430
- const char *mq_end = p;
1431
-
1432
- // Trim
1433
- trim_trailing(mq_start, &mq_end);
1434
-
1435
- if (p >= pe || *p != '{') {
1436
- continue; // Malformed
1437
- }
1438
-
1439
- // Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1440
- // Per W3C spec, comma acts as logical OR - each query is independent
1441
- VALUE media_query_ids = rb_ary_new();
1442
-
1443
- const char *query_start = mq_start;
1444
- for (const char *p_comma = mq_start; p_comma <= mq_end; p_comma++) {
1445
- if (p_comma == mq_end || *p_comma == ',') {
1446
- const char *query_end = p_comma;
1447
-
1448
- // Trim whitespace from this query
1449
- while (query_start < query_end && IS_WHITESPACE(*query_start)) query_start++;
1450
- while (query_end > query_start && IS_WHITESPACE(*(query_end - 1))) query_end--;
1451
-
1452
- if (query_start < query_end) {
1453
- // Parse this individual media query
1454
- const char *mq_ptr = query_start;
1455
- VALUE media_type;
1456
- VALUE media_conditions = Qnil;
1457
-
1458
- if (*mq_ptr == '(') {
1459
- // Starts with '(' - just conditions, type defaults to :all
1460
- media_type = ID2SYM(rb_intern("all"));
1461
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1462
- } else {
1463
- // Extract media type (first word, stopping at whitespace, comma, or '(')
1464
- const char *type_start = mq_ptr;
1465
- while (mq_ptr < query_end && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
1466
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
1467
- media_type = ID2SYM(rb_intern_str(type_str));
1468
-
1469
- // Skip whitespace and "and" keyword if present
1470
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1471
- if (mq_ptr + 3 <= query_end && strncmp(mq_ptr, "and", 3) == 0) {
1472
- mq_ptr += 3;
1473
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1474
- }
1475
-
1476
- // Rest is conditions
1477
- if (mq_ptr < query_end) {
1478
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1479
- }
1480
- }
1481
-
1482
- // Create MediaQuery object for this query
1483
- VALUE media_query = rb_struct_new(cMediaQuery,
1484
- INT2FIX(ctx->media_query_id_counter),
1485
- media_type,
1486
- media_conditions
1487
- );
1488
- rb_ary_push(ctx->media_queries, media_query);
1489
- rb_ary_push(media_query_ids, INT2FIX(ctx->media_query_id_counter));
1490
- ctx->media_query_id_counter++;
1491
- }
1492
-
1493
- // Move to start of next query
1494
- query_start = p_comma + 1;
1495
- }
1496
- }
1497
-
1498
- // If multiple queries, track them as a list for serialization
1499
- int media_query_list_id = -1;
1500
- if (RARRAY_LEN(media_query_ids) > 1) {
1501
- media_query_list_id = ctx->next_media_query_list_id;
1502
- rb_hash_aset(ctx->media_query_lists, INT2FIX(media_query_list_id), media_query_ids);
1503
- ctx->next_media_query_list_id++;
1504
- }
1505
-
1506
- // Use first query ID as the primary one for rules in this block
1507
- int current_media_query_id = FIX2INT(rb_ary_entry(media_query_ids, 0));
1508
-
1509
- // Handle nested @media by combining with parent
1510
- if (parent_media_query_id >= 0) {
1511
- VALUE parent_mq = rb_ary_entry(ctx->media_queries, parent_media_query_id);
1512
- VALUE parent_type = rb_struct_aref(parent_mq, INT2FIX(1)); // type field
1513
- VALUE parent_conditions = rb_struct_aref(parent_mq, INT2FIX(2)); // conditions field
1514
-
1515
- // Get child media query (first one in the list)
1516
- VALUE child_mq = rb_ary_entry(ctx->media_queries, current_media_query_id);
1517
- VALUE child_conditions = rb_struct_aref(child_mq, INT2FIX(2)); // conditions field
1518
-
1519
- // Combined type is parent's type (outermost wins, child type ignored)
1520
- VALUE combined_type = parent_type;
1521
- VALUE combined_conditions;
1522
-
1523
- if (!NIL_P(parent_conditions) && !NIL_P(child_conditions)) {
1524
- combined_conditions = rb_sprintf("%"PRIsVALUE" and %"PRIsVALUE, parent_conditions, child_conditions);
1525
- } else if (!NIL_P(parent_conditions)) {
1526
- combined_conditions = parent_conditions;
1527
- } else {
1528
- combined_conditions = child_conditions;
1529
- }
1530
-
1531
- VALUE combined_mq = rb_struct_new(cMediaQuery,
1532
- INT2FIX(ctx->media_query_id_counter),
1533
- combined_type,
1534
- combined_conditions
1535
- );
1536
- rb_ary_push(ctx->media_queries, combined_mq);
1537
- current_media_query_id = ctx->media_query_id_counter;
1538
- ctx->media_query_id_counter++;
1539
- }
1540
-
1541
- // For backwards compat, also create symbol (will be removed later)
1542
- VALUE child_media_sym = intern_media_query_safe(ctx, mq_start, mq_end - mq_start);
1543
- VALUE combined_media_sym = combine_media_queries(parent_media_sym, child_media_sym);
1544
-
1545
- p++; // Skip opening {
1546
-
1547
- // Find matching closing brace
1548
- const char *block_start = p;
1549
- const char *block_end = find_matching_brace(p, pe);
1550
- p = block_end;
1551
-
1552
- // Recursively parse @media block with new media query context
1553
- ctx->depth++;
1554
- parse_css_recursive(ctx, block_start, block_end, combined_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, current_media_query_id);
1555
- ctx->depth--;
1556
-
1557
- if (p < pe && *p == '}') p++;
2221
+ handle_media_at_rule(ctx, &p, pe, parent_media_sym, parent_media_query_id);
1558
2222
  continue;
1559
2223
  }
1560
2224
 
@@ -1573,173 +2237,45 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1573
2237
  long at_name_len = at_name_end - at_start;
1574
2238
 
1575
2239
  // Check if this is a conditional group rule
1576
- int is_conditional_group =
2240
+ BOOLEAN is_conditional_group =
1577
2241
  (at_name_len == 8 && strncmp(at_start, "supports", 8) == 0) ||
1578
2242
  (at_name_len == 5 && strncmp(at_start, "layer", 5) == 0) ||
1579
2243
  (at_name_len == 9 && strncmp(at_start, "container", 9) == 0) ||
1580
2244
  (at_name_len == 5 && strncmp(at_start, "scope", 5) == 0);
1581
2245
 
1582
2246
  if (is_conditional_group) {
1583
- // Skip to opening brace
1584
- p = at_name_end;
1585
- while (p < pe && *p != '{') p++;
1586
-
1587
- if (p >= pe || *p != '{') {
1588
- continue; // Malformed
1589
- }
1590
-
1591
- p++; // Skip opening {
1592
-
1593
- // Find matching closing brace
1594
- const char *block_start = p;
1595
- const char *block_end = find_matching_brace(p, pe);
1596
- p = block_end;
1597
-
1598
- // Recursively parse block content (preserve parent media context)
1599
- ctx->depth++;
1600
- parse_css_recursive(ctx, block_start, block_end, parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
1601
- ctx->depth--;
1602
-
1603
- if (p < pe && *p == '}') p++;
2247
+ handle_conditional_group_at_rule(ctx, &p, pe, at_start, at_name_end, at_name_len,
2248
+ parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
1604
2249
  continue;
1605
2250
  }
1606
2251
 
1607
2252
  // Check for @keyframes (contains <rule-list>)
1608
2253
  // TODO: Test perf gains by using RB_UNLIKELY(is_keyframes) wrapper
1609
- int is_keyframes =
2254
+ BOOLEAN is_keyframes =
1610
2255
  (at_name_len == 9 && strncmp(at_start, "keyframes", 9) == 0) ||
1611
2256
  (at_name_len == 17 && strncmp(at_start, "-webkit-keyframes", 17) == 0) ||
1612
2257
  (at_name_len == 13 && strncmp(at_start, "-moz-keyframes", 13) == 0);
1613
2258
 
1614
2259
  if (is_keyframes) {
1615
- // Build full selector string: "@keyframes fade"
1616
- const char *selector_start = p; // Points to '@'
1617
- p = at_name_end;
1618
- while (p < pe && *p != '{') p++;
1619
-
1620
- if (p >= pe || *p != '{') {
1621
- continue; // Malformed
1622
- }
1623
-
1624
- const char *selector_end = p;
1625
- while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1626
- selector_end--;
1627
- }
1628
- VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1629
-
1630
- p++; // Skip opening {
1631
-
1632
- // Find matching closing brace
1633
- const char *block_start = p;
1634
- const char *block_end = find_matching_brace(p, pe);
1635
- p = block_end;
1636
-
1637
- // Parse keyframe blocks as rules (from/to/0%/50% etc)
1638
- ParserContext nested_ctx = {
1639
- .rules_array = rb_ary_new(),
1640
- .media_index = rb_hash_new(),
1641
- .selector_lists = rb_hash_new(),
1642
- .imports_array = rb_ary_new(),
1643
- .rule_id_counter = 0,
1644
- .next_selector_list_id = 0,
1645
- .media_query_count = 0,
1646
- .media_cache = NULL,
1647
- .has_nesting = 0,
1648
- .selector_lists_enabled = ctx->selector_lists_enabled,
1649
- .depth = 0
1650
- };
1651
- parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID);
1652
-
1653
- // Get rule ID and increment
1654
- int rule_id = ctx->rule_id_counter++;
1655
-
1656
- // Create AtRule with nested rules
1657
- VALUE at_rule = rb_struct_new(cAtRule,
1658
- INT2FIX(rule_id),
1659
- selector,
1660
- nested_ctx.rules_array, // Array of Rule (keyframe blocks)
1661
- Qnil, // specificity
1662
- Qnil // media_query_id
1663
- );
1664
-
1665
- // Add to rules array
1666
- rb_ary_push(ctx->rules_array, at_rule);
1667
-
1668
- // Add to media index if in media query
1669
- if (!NIL_P(parent_media_sym)) {
1670
- VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1671
- if (NIL_P(rule_ids)) {
1672
- rule_ids = rb_ary_new();
1673
- rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1674
- }
1675
- rb_ary_push(rule_ids, INT2FIX(rule_id));
1676
- }
1677
-
1678
- if (p < pe && *p == '}') p++;
2260
+ handle_keyframes_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
1679
2261
  continue;
1680
2262
  }
1681
2263
 
1682
2264
  // Check for @font-face (contains <declaration-list>)
1683
- int is_font_face = (at_name_len == 9 && strncmp(at_start, "font-face", 9) == 0);
2265
+ BOOLEAN is_font_face = (at_name_len == 9 && strncmp(at_start, "font-face", 9) == 0);
1684
2266
 
1685
2267
  if (is_font_face) {
1686
- // Build selector string: "@font-face"
1687
- const char *selector_start = p; // Points to '@'
1688
- p = at_name_end;
1689
- while (p < pe && *p != '{') p++;
1690
-
1691
- if (p >= pe || *p != '{') {
1692
- continue; // Malformed
1693
- }
1694
-
1695
- const char *selector_end = p;
1696
- while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1697
- selector_end--;
1698
- }
1699
- VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1700
-
1701
- p++; // Skip opening {
1702
-
1703
- // Find matching closing brace
1704
- const char *decl_start = p;
1705
- const char *decl_end = find_matching_brace(p, pe);
1706
- p = decl_end;
1707
-
1708
- // Parse declarations
1709
- VALUE declarations = parse_declarations(decl_start, decl_end, ctx);
1710
-
1711
- // Get rule ID and increment
1712
- int rule_id = ctx->rule_id_counter++;
1713
-
1714
- // Create AtRule with declarations
1715
- VALUE at_rule = rb_struct_new(cAtRule,
1716
- INT2FIX(rule_id),
1717
- selector,
1718
- declarations, // Array of Declaration
1719
- Qnil, // specificity
1720
- Qnil // media_query_id
1721
- );
1722
-
1723
- // Add to rules array
1724
- rb_ary_push(ctx->rules_array, at_rule);
1725
-
1726
- // Add to media index if in media query
1727
- if (!NIL_P(parent_media_sym)) {
1728
- VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1729
- if (NIL_P(rule_ids)) {
1730
- rule_ids = rb_ary_new();
1731
- rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1732
- }
1733
- rb_ary_push(rule_ids, INT2FIX(rule_id));
1734
- }
1735
-
1736
- if (p < pe && *p == '}') p++;
2268
+ handle_font_face_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
1737
2269
  continue;
1738
2270
  }
1739
2271
  }
1740
2272
 
1741
2273
  // Opening brace
1742
2274
  if (*p == '{') {
2275
+ // Check for empty selector (opening brace with no selector before it)
2276
+ if (ctx->check_invalid_selectors && brace_depth == 0 && selector_start == NULL) {
2277
+ raise_parse_error_at(ctx, p, "Invalid selector: empty selector", "invalid_selector");
2278
+ }
1743
2279
  if (brace_depth == 0 && selector_start != NULL) {
1744
2280
  decl_start = p + 1;
1745
2281
  }
@@ -1752,262 +2288,8 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1752
2288
  if (*p == '}') {
1753
2289
  brace_depth--;
1754
2290
  if (brace_depth == 0 && selector_start != NULL && decl_start != NULL) {
1755
- // We've found a complete CSS rule block - now determine if it has nesting
1756
- // Example: .parent { color: red; & .child { font-size: 14px; } }
1757
- // ^selector_start ^decl_start ^p (at })
1758
- int has_nesting = has_nested_selectors(decl_start, p);
1759
-
1760
- // Get selector string
1761
- const char *sel_end = decl_start - 1;
1762
- while (sel_end > selector_start && IS_WHITESPACE(*(sel_end - 1))) {
1763
- sel_end--;
1764
- }
1765
-
1766
- if (!has_nesting) {
1767
- // FAST PATH: No nesting - parse as pure declarations
1768
- VALUE declarations = parse_declarations(decl_start, p, ctx);
1769
-
1770
- // Split on commas to handle multi-selector rules
1771
- // Example: ".a, .b, .c { color: red; }" creates 3 separate rules
1772
- // ^selector_start ^sel_end
1773
- // ^seg_start=seg (scanning for commas)
1774
-
1775
- // Count selectors for selector list tracking
1776
- int selector_count = 1;
1777
- if (ctx->selector_lists_enabled) {
1778
- const char *count_ptr = selector_start;
1779
- while (count_ptr < sel_end) {
1780
- if (*count_ptr == ',') {
1781
- selector_count++;
1782
- }
1783
- count_ptr++;
1784
- }
1785
- }
1786
-
1787
- // Create selector list if enabled and multiple selectors
1788
- int list_id = -1;
1789
- VALUE rule_ids_array = Qnil;
1790
- if (ctx->selector_lists_enabled && selector_count > 1) {
1791
- list_id = ctx->next_selector_list_id++;
1792
- rule_ids_array = rb_ary_new();
1793
- rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
1794
- }
1795
-
1796
- const char *seg_start = selector_start;
1797
- const char *seg = selector_start;
1798
-
1799
- while (seg <= sel_end) {
1800
- if (seg == sel_end || *seg == ',') { // At: ',' or end
1801
- // Trim segment
1802
- while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
1803
- seg_start++;
1804
- }
1805
-
1806
- const char *seg_end_ptr = seg;
1807
- while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
1808
- seg_end_ptr--;
1809
- }
1810
-
1811
- if (seg_end_ptr > seg_start) {
1812
- VALUE selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
1813
-
1814
- // Resolve against parent if nested
1815
- VALUE resolved_selector;
1816
- VALUE nesting_style_val;
1817
- VALUE parent_id_val;
1818
-
1819
- if (!NIL_P(parent_selector)) {
1820
- // This is a nested rule - resolve selector
1821
- VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(selector), RSTRING_LEN(selector));
1822
- resolved_selector = rb_ary_entry(result, 0);
1823
- nesting_style_val = rb_ary_entry(result, 1);
1824
- parent_id_val = parent_rule_id;
1825
- } else {
1826
- // Top-level rule
1827
- resolved_selector = selector;
1828
- nesting_style_val = Qnil;
1829
- parent_id_val = Qnil;
1830
- }
1831
-
1832
- // Get rule ID and increment
1833
- int rule_id = ctx->rule_id_counter++;
1834
-
1835
- // Determine selector_list_id value
1836
- VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
1837
-
1838
- // Deep copy declarations for selector lists to avoid shared state
1839
- // (principle of least surprise - modifying one rule shouldn't affect others)
1840
- VALUE rule_declarations;
1841
- if (list_id >= 0) {
1842
- // Deep copy: both array and Declaration structs inside
1843
- long decl_count = RARRAY_LEN(declarations);
1844
- rule_declarations = rb_ary_new_capa(decl_count);
1845
- for (long k = 0; k < decl_count; k++) {
1846
- VALUE decl = rb_ary_entry(declarations, k);
1847
- VALUE new_decl = rb_struct_new(cDeclaration,
1848
- rb_struct_aref(decl, INT2FIX(DECL_PROPERTY)),
1849
- rb_struct_aref(decl, INT2FIX(DECL_VALUE)),
1850
- rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT))
1851
- );
1852
- rb_ary_push(rule_declarations, new_decl);
1853
- }
1854
- } else {
1855
- rule_declarations = rb_ary_dup(declarations);
1856
- }
1857
-
1858
- // Create Rule
1859
- VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1860
- VALUE rule = rb_struct_new(cRule,
1861
- INT2FIX(rule_id),
1862
- resolved_selector,
1863
- rule_declarations,
1864
- Qnil, // specificity
1865
- parent_id_val,
1866
- nesting_style_val,
1867
- selector_list_id_val,
1868
- media_query_id_val // media_query_id from parent context
1869
- );
1870
-
1871
- // Track rule in selector list if applicable
1872
- if (list_id >= 0) {
1873
- rb_ary_push(rule_ids_array, INT2FIX(rule_id));
1874
- }
1875
-
1876
- // Mark that we have nesting (only set once)
1877
- if (!ctx->has_nesting && !NIL_P(parent_id_val)) {
1878
- ctx->has_nesting = 1;
1879
- }
1880
-
1881
- rb_ary_push(ctx->rules_array, rule);
1882
-
1883
- // Update media index
1884
- update_media_index(ctx, parent_media_sym, rule_id);
1885
- }
1886
-
1887
- seg_start = seg + 1;
1888
- }
1889
- seg++;
1890
- }
1891
- } else {
1892
- // NESTED PATH: Parse mixed declarations + nested rules
1893
- // For each comma-separated parent selector, parse the block with that parent
1894
- //
1895
- // Example: ".a, .b { color: red; & .child { font: 14px; } }"
1896
- // ^selector_start ^sel_end
1897
- // Creates:
1898
- // - .a with declarations [color: red]
1899
- // - .a .child with declarations [font: 14px]
1900
- // - .b with declarations [color: red]
1901
- // - .b .child with declarations [font: 14px]
1902
-
1903
- // Count selectors for selector list tracking
1904
- int selector_count = 1;
1905
- if (ctx->selector_lists_enabled) {
1906
- const char *count_ptr = selector_start;
1907
- while (count_ptr < sel_end) {
1908
- if (*count_ptr == ',') {
1909
- selector_count++;
1910
- }
1911
- count_ptr++;
1912
- }
1913
- }
1914
-
1915
- // Create selector list if enabled and multiple selectors
1916
- int list_id = -1;
1917
- VALUE rule_ids_array = Qnil;
1918
- if (ctx->selector_lists_enabled && selector_count > 1) {
1919
- list_id = ctx->next_selector_list_id++;
1920
- rule_ids_array = rb_ary_new();
1921
- rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
1922
- }
1923
-
1924
- const char *seg_start = selector_start;
1925
- const char *seg = selector_start;
1926
-
1927
- while (seg <= sel_end) {
1928
- if (seg == sel_end || *seg == ',') { // At: ',' or end
1929
- // Trim segment
1930
- while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
1931
- seg_start++;
1932
- }
1933
-
1934
- const char *seg_end_ptr = seg;
1935
- while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
1936
- seg_end_ptr--;
1937
- }
1938
-
1939
- if (seg_end_ptr > seg_start) {
1940
- VALUE current_selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
1941
-
1942
- // Resolve against parent if we're already nested
1943
- VALUE resolved_current;
1944
- VALUE current_nesting_style;
1945
- VALUE current_parent_id;
1946
-
1947
- if (!NIL_P(parent_selector)) {
1948
- VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(current_selector), RSTRING_LEN(current_selector));
1949
- resolved_current = rb_ary_entry(result, 0);
1950
- current_nesting_style = rb_ary_entry(result, 1);
1951
- current_parent_id = parent_rule_id;
1952
- } else {
1953
- resolved_current = current_selector;
1954
- current_nesting_style = Qnil;
1955
- current_parent_id = Qnil;
1956
- }
1957
-
1958
- // Get rule ID for current selector (increment to reserve it)
1959
- int current_rule_id = ctx->rule_id_counter++;
1960
-
1961
- // Reserve parent's position in rules array with placeholder
1962
- // This ensures parent comes before nested rules in array order (per W3C spec)
1963
- long parent_position = RARRAY_LEN(ctx->rules_array);
1964
- rb_ary_push(ctx->rules_array, Qnil);
1965
-
1966
- // Parse mixed block (declarations + nested selectors)
1967
- // Nested rules will be added AFTER the placeholder
1968
- ctx->depth++;
1969
- VALUE parent_declarations = parse_mixed_block(ctx, decl_start, p,
1970
- resolved_current, INT2FIX(current_rule_id), parent_media_sym, parent_media_query_id);
1971
- ctx->depth--;
1972
-
1973
- // Determine selector_list_id value
1974
- VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
1975
-
1976
- // Create parent rule and replace placeholder
1977
- // Always create the rule (even if empty) to avoid edge cases
1978
- VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1979
- VALUE rule = rb_struct_new(cRule,
1980
- INT2FIX(current_rule_id),
1981
- resolved_current,
1982
- parent_declarations,
1983
- Qnil, // specificity
1984
- current_parent_id,
1985
- current_nesting_style,
1986
- selector_list_id_val,
1987
- media_query_id_val // media_query_id from parent context
1988
- );
1989
-
1990
- // Track rule in selector list if applicable
1991
- if (list_id >= 0) {
1992
- rb_ary_push(rule_ids_array, INT2FIX(current_rule_id));
1993
- }
1994
-
1995
- // Mark that we have nesting (only set once)
1996
- if (!ctx->has_nesting && !NIL_P(current_parent_id)) {
1997
- ctx->has_nesting = 1;
1998
- }
1999
-
2000
- // Replace placeholder with actual rule - just pointer assignment, fast!
2001
- rb_ary_store(ctx->rules_array, parent_position, rule);
2002
- update_media_index(ctx, parent_media_sym, current_rule_id);
2003
- }
2004
-
2005
- seg_start = seg + 1;
2006
- }
2007
- seg++;
2008
- }
2009
- }
2010
-
2291
+ finish_rule_block(ctx, selector_start, decl_start, p,
2292
+ parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id);
2011
2293
  selector_start = NULL;
2012
2294
  decl_start = NULL;
2013
2295
  }
@@ -2023,6 +2305,11 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2023
2305
 
2024
2306
  p++;
2025
2307
  }
2308
+
2309
+ // Check for unclosed blocks at end of parsing
2310
+ if (ctx->check_unclosed_blocks && brace_depth > 0) {
2311
+ rb_raise(eParseError, "Unclosed block: missing closing brace");
2312
+ }
2026
2313
  }
2027
2314
 
2028
2315
  /*
@@ -2056,13 +2343,48 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2056
2343
 
2057
2344
  // Read parser options
2058
2345
  VALUE selector_lists_opt = rb_hash_aref(parser_options, ID2SYM(rb_intern("selector_lists")));
2059
- int selector_lists_enabled = (NIL_P(selector_lists_opt) || RTEST(selector_lists_opt)) ? 1 : 0;
2346
+ BOOLEAN selector_lists_enabled = (NIL_P(selector_lists_opt) || RTEST(selector_lists_opt)) ? 1 : 0;
2060
2347
 
2061
2348
  // URL conversion options
2062
2349
  VALUE base_uri = rb_hash_aref(parser_options, ID2SYM(rb_intern("base_uri")));
2063
2350
  VALUE absolute_paths_opt = rb_hash_aref(parser_options, ID2SYM(rb_intern("absolute_paths")));
2064
2351
  VALUE uri_resolver = rb_hash_aref(parser_options, ID2SYM(rb_intern("uri_resolver")));
2065
- int absolute_paths = RTEST(absolute_paths_opt) ? 1 : 0;
2352
+ BOOLEAN absolute_paths = RTEST(absolute_paths_opt) ? 1 : 0;
2353
+
2354
+ // Parse error options
2355
+ VALUE raise_parse_errors_opt = rb_hash_aref(parser_options, ID2SYM(rb_intern("raise_parse_errors")));
2356
+ BOOLEAN check_empty_values = 0;
2357
+ BOOLEAN check_malformed_declarations = 0;
2358
+ BOOLEAN check_invalid_selectors = 0;
2359
+ BOOLEAN check_invalid_selector_syntax = 0;
2360
+ BOOLEAN check_malformed_at_rules = 0;
2361
+ BOOLEAN check_unclosed_blocks = 0;
2362
+
2363
+ if (RTEST(raise_parse_errors_opt)) {
2364
+ if (TYPE(raise_parse_errors_opt) == T_HASH) {
2365
+ // Hash of specific error types
2366
+ VALUE empty_values_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("empty_values")));
2367
+ VALUE malformed_declarations_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("malformed_declarations")));
2368
+ VALUE invalid_selectors_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("invalid_selectors")));
2369
+ VALUE invalid_selector_syntax_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("invalid_selector_syntax")));
2370
+ VALUE malformed_at_rules_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("malformed_at_rules")));
2371
+ VALUE unclosed_blocks_opt = rb_hash_aref(raise_parse_errors_opt, ID2SYM(rb_intern("unclosed_blocks")));
2372
+ check_empty_values = RTEST(empty_values_opt) ? 1 : 0;
2373
+ check_malformed_declarations = RTEST(malformed_declarations_opt) ? 1 : 0;
2374
+ check_invalid_selectors = RTEST(invalid_selectors_opt) ? 1 : 0;
2375
+ check_invalid_selector_syntax = RTEST(invalid_selector_syntax_opt) ? 1 : 0;
2376
+ check_malformed_at_rules = RTEST(malformed_at_rules_opt) ? 1 : 0;
2377
+ check_unclosed_blocks = RTEST(unclosed_blocks_opt) ? 1 : 0;
2378
+ } else {
2379
+ // true - enable all checks
2380
+ check_empty_values = 1;
2381
+ check_malformed_declarations = 1;
2382
+ check_invalid_selectors = 1;
2383
+ check_invalid_selector_syntax = 1;
2384
+ check_malformed_at_rules = 1;
2385
+ check_unclosed_blocks = 1;
2386
+ }
2387
+ }
2066
2388
 
2067
2389
  const char *css = RSTRING_PTR(css_string);
2068
2390
  const char *pe = css + RSTRING_LEN(css_string);
@@ -2107,7 +2429,6 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2107
2429
  ctx.media_query_id_counter = 0; // Start from 0
2108
2430
  ctx.next_media_query_list_id = 0; // Start from 0
2109
2431
  ctx.media_query_count = 0;
2110
- ctx.media_cache = NULL; // Removed - no perf benefit
2111
2432
  ctx.has_nesting = 0; // Will be set to 1 if any nested rules are created
2112
2433
  ctx.selector_lists_enabled = selector_lists_enabled;
2113
2434
  ctx.depth = 0; // Start at depth 0
@@ -2115,6 +2436,14 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2115
2436
  ctx.base_uri = base_uri;
2116
2437
  ctx.uri_resolver = uri_resolver;
2117
2438
  ctx.absolute_paths = absolute_paths;
2439
+ // Parse error options
2440
+ ctx.css_string = css_string;
2441
+ ctx.check_empty_values = check_empty_values;
2442
+ ctx.check_malformed_declarations = check_malformed_declarations;
2443
+ ctx.check_invalid_selectors = check_invalid_selectors;
2444
+ ctx.check_invalid_selector_syntax = check_invalid_selector_syntax;
2445
+ ctx.check_malformed_at_rules = check_malformed_at_rules;
2446
+ ctx.check_unclosed_blocks = check_unclosed_blocks;
2118
2447
 
2119
2448
  // Parse CSS (top-level, no parent context)
2120
2449
  DEBUG_PRINTF("[PARSE] Starting parse_css_recursive from: %.80s\n", p);