cataract 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,6 @@ typedef struct {
31
31
  int media_query_id_counter; // Next MediaQuery ID (0-indexed)
32
32
  int next_media_query_list_id; // Next media query list ID (0-indexed)
33
33
  int media_query_count; // Safety limit for media queries
34
- st_table *media_cache; // Parse-time cache: string => parsed media types
35
34
  BOOLEAN has_nesting; // Set to 1 if any nested rules are created
36
35
  BOOLEAN selector_lists_enabled; // Parser option: track selector lists (1=enabled, 0=disabled)
37
36
  BOOLEAN depth; // Current recursion depth (safety limit)
@@ -49,6 +48,38 @@ typedef struct {
49
48
  BOOLEAN check_unclosed_blocks; // Raise error on missing closing braces
50
49
  } ParserContext;
51
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
+
52
83
  // Macro to skip CSS comments /* ... */
53
84
  // Usage: SKIP_COMMENT(p, end) where p is current position, end is limit
54
85
  // Side effect: advances p past the comment and continues to next iteration
@@ -718,14 +749,17 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
718
749
  }
719
750
  if (pos >= end) break;
720
751
 
721
- // Find property (up to colon)
722
- // Example: "color: red; ..."
723
- // ^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.
724
759
  const char *prop_start = pos;
725
- while (pos < end && *pos != ':' && *pos != ';') pos++;
726
-
727
- // Malformed declaration - skip to next semicolon to recover
728
- 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
729
763
  if (ctx->check_malformed_declarations) {
730
764
  // Extract property text for error message
731
765
  const char *prop_text_end = pos;
@@ -771,74 +805,18 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
771
805
  continue;
772
806
  }
773
807
 
774
- const char *prop_end = pos;
775
- // Trim whitespace from property
776
- trim_trailing(prop_start, &prop_end);
777
- trim_leading(&prop_start, prop_end);
778
-
779
- pos++; // Skip colon
780
-
781
- // Skip whitespace after colon
782
- while (pos < end && IS_WHITESPACE(*pos)) {
783
- pos++;
784
- }
785
-
786
- // Find value (up to semicolon or end)
787
- // Must track paren depth to avoid breaking on semicolons inside url() or rgba()
788
- // Example: "url(data:image/svg+xml;base64,...); next-prop: ..."
789
- // ^val_start ^pos (at ; outside parens)
790
- const char *val_start = pos;
791
- int paren_depth = 0;
792
- while (pos < end) {
793
- if (*pos == '(') { // At: '('
794
- paren_depth++; // Depth: 1
795
- } else if (*pos == ')') { // At: ')'
796
- paren_depth--; // Depth: 0
797
- } else if (*pos == ';' && paren_depth == 0) { // At: ';' (outside parens)
798
- break; // Found terminating semicolon
799
- }
800
- pos++;
801
- }
802
- const char *val_end = pos;
803
-
804
- // Trim trailing whitespace from value
805
- trim_trailing(val_start, &val_end);
806
-
807
- // Check for !important
808
- BOOLEAN is_important = 0;
809
- if (val_end - val_start >= 10) { // strlen("!important") = 10
810
- const char *check = val_end - 10;
811
- while (check < val_end && IS_WHITESPACE(*check)) check++;
812
- if (check < val_end && *check == '!') {
813
- check++;
814
- while (check < val_end && IS_WHITESPACE(*check)) check++;
815
- // strncmp safely handles remaining length check
816
- if (check + 9 <= val_end && strncmp(check, "important", 9) == 0) {
817
- is_important = 1;
818
- const char *important_pos = check - 1;
819
- while (important_pos > val_start && (IS_WHITESPACE(*(important_pos-1)) || *(important_pos-1) == '!')) {
820
- important_pos--;
821
- }
822
- val_end = important_pos;
823
- }
824
- }
825
- }
826
-
827
- // Final trim
828
- trim_trailing(val_start, &val_end);
829
-
830
808
  // Check for empty value
831
- if (val_end <= val_start && ctx->check_empty_values) {
832
- long prop_len = prop_end - prop_start;
809
+ if (span.val_end <= span.val_start && ctx->check_empty_values) {
810
+ long prop_len = span.prop_end - span.prop_start;
833
811
  const char *css = RSTRING_PTR(ctx->css_string);
834
- long error_pos = val_start - css;
812
+ long error_pos = span.val_start - css;
835
813
 
836
814
  // Build error message
837
815
  int display_len = (prop_len > 200) ? 200 : (int)prop_len;
838
816
  char error_msg[256];
839
817
  snprintf(error_msg, sizeof(error_msg),
840
818
  "Empty value for property '%.*s'",
841
- display_len, prop_start);
819
+ display_len, span.prop_start);
842
820
 
843
821
  // Build keyword args hash
844
822
  VALUE kwargs = rb_hash_new();
@@ -856,9 +834,9 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
856
834
  }
857
835
 
858
836
  // Skip if value is empty
859
- if (val_end > val_start) {
860
- long prop_len = prop_end - prop_start;
861
- 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;
862
840
 
863
841
  // Check property name length
864
842
  if (prop_len > MAX_PROPERTY_NAME_LENGTH) {
@@ -875,18 +853,18 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
875
853
  }
876
854
 
877
855
  // Create property string - use UTF-8 to support custom properties with Unicode
878
- VALUE property = rb_utf8_str_new(prop_start, prop_len);
856
+ VALUE property = rb_utf8_str_new(span.prop_start, prop_len);
879
857
  // Custom properties (--foo) are case-sensitive and can contain Unicode
880
858
  // Regular properties are ASCII-only and case-insensitive
881
- if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
859
+ if (!(prop_len >= 2 && span.prop_start[0] == '-' && span.prop_start[1] == '-')) {
882
860
  // Regular property: force ASCII encoding and lowercase
883
861
  rb_enc_associate(property, rb_usascii_encoding());
884
862
  property = lowercase_property(property);
885
863
  }
886
- VALUE value = rb_utf8_str_new(val_start, val_len);
864
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
887
865
 
888
866
  // Convert relative URLs to absolute if enabled
889
- if (ctx && ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
867
+ if (ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
890
868
  value = convert_urls_in_value(value, ctx->base_uri, ctx->uri_resolver);
891
869
  }
892
870
 
@@ -894,13 +872,12 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
894
872
  VALUE decl = rb_struct_new(cDeclaration,
895
873
  property,
896
874
  value,
897
- is_important ? Qtrue : Qfalse
875
+ span.is_important ? Qtrue : Qfalse
898
876
  );
899
877
 
900
878
  rb_ary_push(declarations, decl);
901
879
  }
902
-
903
- if (pos < end && *pos == ';') pos++; // Skip semicolon if present
880
+ // parse_one_declaration already advanced pos past the ';' on success
904
881
  }
905
882
 
906
883
  return declarations;
@@ -989,6 +966,54 @@ static VALUE intern_media_query_safe(ParserContext *ctx, const char *query_str,
989
966
  return sym;
990
967
  }
991
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
+
992
1017
  /*
993
1018
  * Parse mixed declarations and nested selectors from a block
994
1019
  * Used when a CSS rule block contains both declarations and nested rules
@@ -1044,31 +1069,9 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1044
1069
  trim_trailing(media_query_start, &media_query_end_trimmed);
1045
1070
 
1046
1071
  // Parse media query and create MediaQuery object
1047
- const char *mq_ptr = media_query_start;
1048
1072
  VALUE media_type;
1049
- VALUE media_conditions = Qnil;
1050
-
1051
- if (*mq_ptr == '(') {
1052
- // Starts with '(' - just conditions, type defaults to :all
1053
- media_type = ID2SYM(rb_intern("all"));
1054
- media_conditions = rb_utf8_str_new(mq_ptr, media_query_end_trimmed - mq_ptr);
1055
- } else {
1056
- // Extract media type (first word)
1057
- const char *type_start = mq_ptr;
1058
- while (mq_ptr < media_query_end_trimmed && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
1059
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
1060
- media_type = ID2SYM(rb_intern_str(type_str));
1061
-
1062
- // Skip "and" keyword if present
1063
- while (mq_ptr < media_query_end_trimmed && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1064
- if (mq_ptr + 3 <= media_query_end_trimmed && strncmp(mq_ptr, "and", 3) == 0) {
1065
- mq_ptr += 3;
1066
- while (mq_ptr < media_query_end_trimmed && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1067
- }
1068
- if (mq_ptr < media_query_end_trimmed) {
1069
- media_conditions = rb_utf8_str_new(mq_ptr, media_query_end_trimmed - mq_ptr);
1070
- }
1071
- }
1073
+ VALUE media_conditions;
1074
+ parse_single_media_query(media_query_start, media_query_end_trimmed, &media_type, &media_conditions);
1072
1075
 
1073
1076
  // Create MediaQuery object
1074
1077
  VALUE media_query = rb_struct_new(cMediaQuery,
@@ -1283,49 +1286,18 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1283
1286
  }
1284
1287
 
1285
1288
  // This is a declaration - parse it
1286
- const char *prop_start = p;
1287
- while (p < end && *p != ':' && *p != ';' && *p != '{') p++;
1288
- if (p >= end || *p != ':') {
1289
+ struct declaration_span span;
1290
+ if (!parse_one_declaration(&p, end, 1, &span)) {
1289
1291
  // Malformed - skip to semicolon
1290
1292
  while (p < end && *p != ';') p++;
1291
1293
  if (p < end) p++;
1292
1294
  continue;
1293
1295
  }
1294
1296
 
1295
- const char *prop_end = p;
1296
- trim_trailing(prop_start, &prop_end);
1297
-
1298
- p++; // Skip :
1299
- trim_leading(&p, end);
1300
-
1301
- const char *val_start = p;
1302
- BOOLEAN important = 0;
1303
-
1304
- // Find end of value (semicolon or closing brace or end)
1305
- while (p < end && *p != ';' && *p != '}') p++;
1306
- const char *val_end = p;
1307
-
1308
- // Check for !important
1309
- const char *important_check = val_end - 10; // " !important"
1310
- if (important_check >= val_start) {
1311
- trim_trailing(val_start, &val_end);
1312
- if (val_end - val_start >= 10) {
1313
- if (strncmp(val_end - 10, "!important", 10) == 0) {
1314
- important = 1;
1315
- val_end -= 10;
1316
- trim_trailing(val_start, &val_end);
1317
- }
1318
- }
1319
- } else {
1320
- trim_trailing(val_start, &val_end);
1321
- }
1322
-
1323
- if (p < end && *p == ';') p++;
1324
-
1325
1297
  // Create declaration
1326
- if (prop_end > prop_start && val_end > val_start) {
1327
- long prop_len = prop_end - prop_start;
1328
- 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;
1329
1301
 
1330
1302
  // Check property name length
1331
1303
  if (prop_len > MAX_PROPERTY_NAME_LENGTH) {
@@ -1342,15 +1314,15 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1342
1314
  }
1343
1315
 
1344
1316
  // Create property string - use UTF-8 to support custom properties with Unicode
1345
- VALUE property = rb_utf8_str_new(prop_start, prop_len);
1317
+ VALUE property = rb_utf8_str_new(span.prop_start, prop_len);
1346
1318
  // Custom properties (--foo) are case-sensitive and can contain Unicode
1347
1319
  // Regular properties are ASCII-only and case-insensitive
1348
- if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
1320
+ if (!(prop_len >= 2 && span.prop_start[0] == '-' && span.prop_start[1] == '-')) {
1349
1321
  // Regular property: force ASCII encoding and lowercase
1350
1322
  rb_enc_associate(property, rb_usascii_encoding());
1351
1323
  property = lowercase_property(property);
1352
1324
  }
1353
- VALUE value = rb_utf8_str_new(val_start, val_len);
1325
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
1354
1326
 
1355
1327
  // Convert relative URLs to absolute if enabled
1356
1328
  if (ctx->absolute_paths && !NIL_P(ctx->base_uri)) {
@@ -1360,7 +1332,7 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1360
1332
  VALUE decl = rb_struct_new(cDeclaration,
1361
1333
  property,
1362
1334
  value,
1363
- important ? Qtrue : Qfalse
1335
+ span.is_important ? Qtrue : Qfalse
1364
1336
  );
1365
1337
 
1366
1338
  rb_ary_push(declarations, decl);
@@ -1471,29 +1443,9 @@ static void parse_import_statement(ParserContext *ctx, const char **p_ptr, const
1471
1443
 
1472
1444
  if (query_start < query_end) {
1473
1445
  // Parse this individual media query
1474
- const char *mq_ptr = query_start;
1475
1446
  VALUE media_type;
1476
- VALUE media_conditions = Qnil;
1477
-
1478
- if (*mq_ptr == '(') {
1479
- // Starts with '(' - just conditions, type defaults to :all
1480
- media_type = ID2SYM(rb_intern("all"));
1481
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1482
- } else {
1483
- // Extract media type (first word)
1484
- const char *type_start = mq_ptr;
1485
- while (mq_ptr < query_end && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
1486
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
1487
- media_type = ID2SYM(rb_intern_str(type_str));
1488
-
1489
- // Skip whitespace
1490
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1491
-
1492
- // Check if there are conditions (rest of string)
1493
- if (mq_ptr < query_end) {
1494
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1495
- }
1496
- }
1447
+ VALUE media_conditions;
1448
+ parse_single_media_query(query_start, query_end, &media_type, &media_conditions);
1497
1449
 
1498
1450
  // Create MediaQuery struct
1499
1451
  VALUE media_query = rb_struct_new(cMediaQuery,
@@ -1550,6 +1502,665 @@ static void parse_import_statement(ParserContext *ctx, const char **p_ptr, const
1550
1502
  RB_GC_GUARD(import_stmt);
1551
1503
  }
1552
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
+
1553
2164
  /*
1554
2165
  * Parse CSS recursively with media query context and optional parent selector for nesting
1555
2166
  *
@@ -1607,166 +2218,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1607
2218
  // Check for @media at-rule (only at depth 0)
1608
2219
  if (RB_UNLIKELY(brace_depth == 0 && p + 6 < pe && *p == '@' &&
1609
2220
  strncmp(p + 1, "media", 5) == 0 && IS_WHITESPACE(p[6]))) {
1610
- p += 6; // Skip "@media"
1611
-
1612
- // Skip whitespace
1613
- while (p < pe && IS_WHITESPACE(*p)) p++;
1614
-
1615
- // Find media query (up to opening brace)
1616
- const char *mq_start = p;
1617
- while (p < pe && *p != '{') p++;
1618
- const char *mq_end = p;
1619
-
1620
- // Trim
1621
- trim_trailing(mq_start, &mq_end);
1622
-
1623
- // Check for empty media query
1624
- if (mq_end <= mq_start) {
1625
- if (ctx->check_malformed_at_rules) {
1626
- raise_parse_error_at(ctx, mq_start, "Malformed @media: missing media query", "malformed_at_rule");
1627
- } else {
1628
- // Empty media query with check disabled - skip @media wrapper and parse contents as regular rules
1629
- if (p >= pe || *p != '{') {
1630
- continue; // Malformed structure
1631
- }
1632
- p++; // Skip opening {
1633
- const char *block_start = p;
1634
- const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1635
- p = block_end;
1636
-
1637
- // Parse block contents with NO media query context
1638
- ctx->depth++;
1639
- parse_css_recursive(ctx, block_start, block_end, parent_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, parent_media_query_id);
1640
- ctx->depth--;
1641
-
1642
- if (p < pe && *p == '}') p++;
1643
- continue;
1644
- }
1645
- }
1646
-
1647
- if (p >= pe || *p != '{') {
1648
- continue; // Malformed
1649
- }
1650
-
1651
- // Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1652
- // Per W3C spec, comma acts as logical OR - each query is independent
1653
- VALUE media_query_ids = rb_ary_new();
1654
-
1655
- const char *query_start = mq_start;
1656
- for (const char *p_comma = mq_start; p_comma <= mq_end; p_comma++) {
1657
- if (p_comma == mq_end || *p_comma == ',') {
1658
- const char *query_end = p_comma;
1659
-
1660
- // Trim whitespace from this query
1661
- while (query_start < query_end && IS_WHITESPACE(*query_start)) query_start++;
1662
- while (query_end > query_start && IS_WHITESPACE(*(query_end - 1))) query_end--;
1663
-
1664
- if (query_start < query_end) {
1665
- // Parse this individual media query
1666
- const char *mq_ptr = query_start;
1667
- VALUE media_type;
1668
- VALUE media_conditions = Qnil;
1669
-
1670
- if (*mq_ptr == '(') {
1671
- // Starts with '(' - just conditions, type defaults to :all
1672
- media_type = ID2SYM(rb_intern("all"));
1673
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1674
- } else {
1675
- // Extract media type (first word, stopping at whitespace, comma, or '(')
1676
- const char *type_start = mq_ptr;
1677
- while (mq_ptr < query_end && !IS_WHITESPACE(*mq_ptr) && *mq_ptr != '(') mq_ptr++;
1678
- VALUE type_str = rb_utf8_str_new(type_start, mq_ptr - type_start);
1679
- media_type = ID2SYM(rb_intern_str(type_str));
1680
-
1681
- // Skip whitespace and "and" keyword if present
1682
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1683
- if (mq_ptr + 3 <= query_end && strncmp(mq_ptr, "and", 3) == 0) {
1684
- mq_ptr += 3;
1685
- while (mq_ptr < query_end && IS_WHITESPACE(*mq_ptr)) mq_ptr++;
1686
- }
1687
-
1688
- // Rest is conditions
1689
- if (mq_ptr < query_end) {
1690
- media_conditions = rb_utf8_str_new(mq_ptr, query_end - mq_ptr);
1691
- }
1692
- }
1693
-
1694
- // Create MediaQuery object for this query
1695
- VALUE media_query = rb_struct_new(cMediaQuery,
1696
- INT2FIX(ctx->media_query_id_counter),
1697
- media_type,
1698
- media_conditions
1699
- );
1700
- rb_ary_push(ctx->media_queries, media_query);
1701
- rb_ary_push(media_query_ids, INT2FIX(ctx->media_query_id_counter));
1702
- ctx->media_query_id_counter++;
1703
- }
1704
-
1705
- // Move to start of next query
1706
- query_start = p_comma + 1;
1707
- }
1708
- }
1709
-
1710
- // If multiple queries, track them as a list for serialization
1711
- int media_query_list_id = -1;
1712
- if (RARRAY_LEN(media_query_ids) > 1) {
1713
- media_query_list_id = ctx->next_media_query_list_id;
1714
- rb_hash_aset(ctx->media_query_lists, INT2FIX(media_query_list_id), media_query_ids);
1715
- ctx->next_media_query_list_id++;
1716
- }
1717
-
1718
- // Use first query ID as the primary one for rules in this block
1719
- int current_media_query_id = FIX2INT(rb_ary_entry(media_query_ids, 0));
1720
-
1721
- // Handle nested @media by combining with parent
1722
- if (parent_media_query_id >= 0) {
1723
- VALUE parent_mq = rb_ary_entry(ctx->media_queries, parent_media_query_id);
1724
- VALUE parent_type = rb_struct_aref(parent_mq, INT2FIX(1)); // type field
1725
- VALUE parent_conditions = rb_struct_aref(parent_mq, INT2FIX(2)); // conditions field
1726
-
1727
- // Get child media query (first one in the list)
1728
- VALUE child_mq = rb_ary_entry(ctx->media_queries, current_media_query_id);
1729
- VALUE child_conditions = rb_struct_aref(child_mq, INT2FIX(2)); // conditions field
1730
-
1731
- // Combined type is parent's type (outermost wins, child type ignored)
1732
- VALUE combined_type = parent_type;
1733
- VALUE combined_conditions;
1734
-
1735
- if (!NIL_P(parent_conditions) && !NIL_P(child_conditions)) {
1736
- combined_conditions = rb_sprintf("%"PRIsVALUE" and %"PRIsVALUE, parent_conditions, child_conditions);
1737
- } else if (!NIL_P(parent_conditions)) {
1738
- combined_conditions = parent_conditions;
1739
- } else {
1740
- combined_conditions = child_conditions;
1741
- }
1742
-
1743
- VALUE combined_mq = rb_struct_new(cMediaQuery,
1744
- INT2FIX(ctx->media_query_id_counter),
1745
- combined_type,
1746
- combined_conditions
1747
- );
1748
- rb_ary_push(ctx->media_queries, combined_mq);
1749
- current_media_query_id = ctx->media_query_id_counter;
1750
- ctx->media_query_id_counter++;
1751
- }
1752
-
1753
- // For backwards compat, also create symbol (will be removed later)
1754
- VALUE child_media_sym = intern_media_query_safe(ctx, mq_start, mq_end - mq_start);
1755
- VALUE combined_media_sym = combine_media_queries(parent_media_sym, child_media_sym);
1756
-
1757
- p++; // Skip opening {
1758
-
1759
- // Find matching closing brace
1760
- const char *block_start = p;
1761
- const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1762
- p = block_end;
1763
-
1764
- // Recursively parse @media block with new media query context
1765
- ctx->depth++;
1766
- parse_css_recursive(ctx, block_start, block_end, combined_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, current_media_query_id);
1767
- ctx->depth--;
1768
-
1769
- if (p < pe && *p == '}') p++;
2221
+ handle_media_at_rule(ctx, &p, pe, parent_media_sym, parent_media_query_id);
1770
2222
  continue;
1771
2223
  }
1772
2224
 
@@ -1792,47 +2244,8 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1792
2244
  (at_name_len == 5 && strncmp(at_start, "scope", 5) == 0);
1793
2245
 
1794
2246
  if (is_conditional_group) {
1795
- // Check if this rule requires a condition
1796
- BOOLEAN requires_condition =
1797
- (at_name_len == 8 && strncmp(at_start, "supports", 8) == 0) ||
1798
- (at_name_len == 9 && strncmp(at_start, "container", 9) == 0);
1799
-
1800
- // Extract condition (between at-rule name and opening brace)
1801
- const char *cond_start = at_name_end;
1802
- while (cond_start < pe && IS_WHITESPACE(*cond_start)) cond_start++;
1803
-
1804
- // Skip to opening brace
1805
- p = at_name_end;
1806
- while (p < pe && *p != '{') p++;
1807
-
1808
- if (p >= pe || *p != '{') {
1809
- continue; // Malformed
1810
- }
1811
-
1812
- // Trim condition
1813
- const char *cond_end = p;
1814
- while (cond_end > cond_start && IS_WHITESPACE(*(cond_end - 1))) cond_end--;
1815
-
1816
- // Check for missing condition
1817
- if (requires_condition && cond_end <= cond_start && ctx->check_malformed_at_rules) {
1818
- char error_msg[100];
1819
- snprintf(error_msg, sizeof(error_msg), "Malformed @%.*s: missing condition", (int)at_name_len, at_start);
1820
- raise_parse_error_at(ctx, at_start - 1, error_msg, "malformed_at_rule");
1821
- }
1822
-
1823
- p++; // Skip opening {
1824
-
1825
- // Find matching closing brace
1826
- const char *block_start = p;
1827
- const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1828
- p = block_end;
1829
-
1830
- // Recursively parse block content (preserve parent media context)
1831
- ctx->depth++;
1832
- parse_css_recursive(ctx, block_start, block_end, parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
1833
- ctx->depth--;
1834
-
1835
- 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);
1836
2249
  continue;
1837
2250
  }
1838
2251
 
@@ -1844,70 +2257,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1844
2257
  (at_name_len == 13 && strncmp(at_start, "-moz-keyframes", 13) == 0);
1845
2258
 
1846
2259
  if (is_keyframes) {
1847
- // Build full selector string: "@keyframes fade"
1848
- const char *selector_start = p; // Points to '@'
1849
- p = at_name_end;
1850
- while (p < pe && *p != '{') p++;
1851
-
1852
- if (p >= pe || *p != '{') {
1853
- continue; // Malformed
1854
- }
1855
-
1856
- const char *selector_end = p;
1857
- while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1858
- selector_end--;
1859
- }
1860
- VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1861
-
1862
- p++; // Skip opening {
1863
-
1864
- // Find matching closing brace
1865
- const char *block_start = p;
1866
- const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1867
- p = block_end;
1868
-
1869
- // Parse keyframe blocks as rules (from/to/0%/50% etc)
1870
- ParserContext nested_ctx = {
1871
- .rules_array = rb_ary_new(),
1872
- .media_index = rb_hash_new(),
1873
- .selector_lists = rb_hash_new(),
1874
- .imports_array = rb_ary_new(),
1875
- .rule_id_counter = 0,
1876
- .next_selector_list_id = 0,
1877
- .media_query_count = 0,
1878
- .media_cache = NULL,
1879
- .has_nesting = 0,
1880
- .selector_lists_enabled = ctx->selector_lists_enabled,
1881
- .depth = 0
1882
- };
1883
- parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID);
1884
-
1885
- // Get rule ID and increment
1886
- int rule_id = ctx->rule_id_counter++;
1887
-
1888
- // Create AtRule with nested rules
1889
- VALUE at_rule = rb_struct_new(cAtRule,
1890
- INT2FIX(rule_id),
1891
- selector,
1892
- nested_ctx.rules_array, // Array of Rule (keyframe blocks)
1893
- Qnil, // specificity
1894
- Qnil // media_query_id
1895
- );
1896
-
1897
- // Add to rules array
1898
- rb_ary_push(ctx->rules_array, at_rule);
1899
-
1900
- // Add to media index if in media query
1901
- if (!NIL_P(parent_media_sym)) {
1902
- VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1903
- if (NIL_P(rule_ids)) {
1904
- rule_ids = rb_ary_new();
1905
- rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1906
- }
1907
- rb_ary_push(rule_ids, INT2FIX(rule_id));
1908
- }
1909
-
1910
- if (p < pe && *p == '}') p++;
2260
+ handle_keyframes_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
1911
2261
  continue;
1912
2262
  }
1913
2263
 
@@ -1915,57 +2265,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1915
2265
  BOOLEAN is_font_face = (at_name_len == 9 && strncmp(at_start, "font-face", 9) == 0);
1916
2266
 
1917
2267
  if (is_font_face) {
1918
- // Build selector string: "@font-face"
1919
- const char *selector_start = p; // Points to '@'
1920
- p = at_name_end;
1921
- while (p < pe && *p != '{') p++;
1922
-
1923
- if (p >= pe || *p != '{') {
1924
- continue; // Malformed
1925
- }
1926
-
1927
- const char *selector_end = p;
1928
- while (selector_end > selector_start && IS_WHITESPACE(*(selector_end - 1))) {
1929
- selector_end--;
1930
- }
1931
- VALUE selector = rb_utf8_str_new(selector_start, selector_end - selector_start);
1932
-
1933
- p++; // Skip opening {
1934
-
1935
- // Find matching closing brace
1936
- const char *decl_start = p;
1937
- const char *decl_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1938
- p = decl_end;
1939
-
1940
- // Parse declarations
1941
- VALUE declarations = parse_declarations(decl_start, decl_end, ctx);
1942
-
1943
- // Get rule ID and increment
1944
- int rule_id = ctx->rule_id_counter++;
1945
-
1946
- // Create AtRule with declarations
1947
- VALUE at_rule = rb_struct_new(cAtRule,
1948
- INT2FIX(rule_id),
1949
- selector,
1950
- declarations, // Array of Declaration
1951
- Qnil, // specificity
1952
- Qnil // media_query_id
1953
- );
1954
-
1955
- // Add to rules array
1956
- rb_ary_push(ctx->rules_array, at_rule);
1957
-
1958
- // Add to media index if in media query
1959
- if (!NIL_P(parent_media_sym)) {
1960
- VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1961
- if (NIL_P(rule_ids)) {
1962
- rule_ids = rb_ary_new();
1963
- rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1964
- }
1965
- rb_ary_push(rule_ids, INT2FIX(rule_id));
1966
- }
1967
-
1968
- if (p < pe && *p == '}') p++;
2268
+ handle_font_face_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
1969
2269
  continue;
1970
2270
  }
1971
2271
  }
@@ -1988,313 +2288,8 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1988
2288
  if (*p == '}') {
1989
2289
  brace_depth--;
1990
2290
  if (brace_depth == 0 && selector_start != NULL && decl_start != NULL) {
1991
- // We've found a complete CSS rule block - now determine if it has nesting
1992
- // Example: .parent { color: red; & .child { font-size: 14px; } }
1993
- // ^selector_start ^decl_start ^p (at })
1994
- BOOLEAN has_nesting = has_nested_selectors(decl_start, p);
1995
-
1996
- // Get selector string
1997
- const char *sel_end = decl_start - 1;
1998
- while (sel_end > selector_start && IS_WHITESPACE(*(sel_end - 1))) {
1999
- sel_end--;
2000
- }
2001
-
2002
- // Check for empty selector
2003
- if (ctx->check_invalid_selectors && sel_end <= selector_start) {
2004
- const char *css = RSTRING_PTR(ctx->css_string);
2005
- long error_pos = selector_start - css;
2006
-
2007
- // Build keyword args hash
2008
- VALUE kwargs = rb_hash_new();
2009
- rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
2010
- rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
2011
- rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("invalid_selector")));
2012
-
2013
- VALUE msg_str = rb_str_new_cstr("Invalid selector: empty selector");
2014
- VALUE argv[2] = {msg_str, kwargs};
2015
- VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
2016
- rb_exc_raise(error);
2017
- }
2018
-
2019
- if (!has_nesting) {
2020
- // FAST PATH: No nesting - parse as pure declarations
2021
- VALUE declarations = parse_declarations(decl_start, p, ctx);
2022
-
2023
- // Split on commas to handle multi-selector rules
2024
- // Example: ".a, .b, .c { color: red; }" creates 3 separate rules
2025
- // ^selector_start ^sel_end
2026
- // ^seg_start=seg (scanning for commas)
2027
-
2028
- // Count selectors for selector list tracking
2029
- int selector_count = 1;
2030
- if (ctx->selector_lists_enabled) {
2031
- const char *count_ptr = selector_start;
2032
- while (count_ptr < sel_end) {
2033
- if (*count_ptr == ',') {
2034
- selector_count++;
2035
- }
2036
- count_ptr++;
2037
- }
2038
- }
2039
-
2040
- // Create selector list if enabled and multiple selectors
2041
- int list_id = -1;
2042
- VALUE rule_ids_array = Qnil;
2043
- if (ctx->selector_lists_enabled && selector_count > 1) {
2044
- list_id = ctx->next_selector_list_id++;
2045
- rule_ids_array = rb_ary_new();
2046
- rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
2047
- }
2048
-
2049
- const char *seg_start = selector_start;
2050
- const char *seg = selector_start;
2051
-
2052
- while (seg <= sel_end) {
2053
- if (seg == sel_end || *seg == ',') { // At: ',' or end
2054
- // Trim segment
2055
- while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
2056
- seg_start++;
2057
- }
2058
-
2059
- const char *seg_end_ptr = seg;
2060
- while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
2061
- seg_end_ptr--;
2062
- }
2063
-
2064
- if (seg_end_ptr > seg_start) {
2065
- // Check for invalid selectors
2066
- if (ctx->check_invalid_selectors) {
2067
- // Check if selector starts with combinator
2068
- char first_char = *seg_start;
2069
- if (first_char == '>' || first_char == '+' || first_char == '~') {
2070
- const char *css = RSTRING_PTR(ctx->css_string);
2071
- long error_pos = seg_start - css;
2072
-
2073
- char error_msg[256];
2074
- snprintf(error_msg, sizeof(error_msg),
2075
- "Invalid selector: selector cannot start with combinator '%c'",
2076
- first_char);
2077
-
2078
- // Build keyword args hash
2079
- VALUE kwargs = rb_hash_new();
2080
- rb_hash_aset(kwargs, ID2SYM(rb_intern("css")), ctx->css_string);
2081
- rb_hash_aset(kwargs, ID2SYM(rb_intern("pos")), LONG2NUM(error_pos));
2082
- rb_hash_aset(kwargs, ID2SYM(rb_intern("type")), ID2SYM(rb_intern("invalid_selector")));
2083
-
2084
- VALUE msg_str = rb_str_new_cstr(error_msg);
2085
- VALUE argv[2] = {msg_str, kwargs};
2086
- VALUE error = rb_funcallv_kw(eParseError, rb_intern("new"), 2, argv, RB_PASS_KEYWORDS);
2087
- rb_exc_raise(error);
2088
- }
2089
- }
2090
-
2091
- // Check for invalid selector syntax (whitelist validation)
2092
- if (ctx->check_invalid_selector_syntax && !is_valid_selector(seg_start, seg_end_ptr)) {
2093
- raise_parse_error_at(ctx, seg_start, "Invalid selector syntax: selector contains invalid characters", "invalid_selector_syntax");
2094
- }
2095
-
2096
- VALUE selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
2097
-
2098
- // Resolve against parent if nested
2099
- VALUE resolved_selector;
2100
- VALUE nesting_style_val;
2101
- VALUE parent_id_val;
2102
-
2103
- if (!NIL_P(parent_selector)) {
2104
- // This is a nested rule - resolve selector
2105
- VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(selector), RSTRING_LEN(selector));
2106
- resolved_selector = rb_ary_entry(result, 0);
2107
- nesting_style_val = rb_ary_entry(result, 1);
2108
- parent_id_val = parent_rule_id;
2109
- } else {
2110
- // Top-level rule
2111
- resolved_selector = selector;
2112
- nesting_style_val = Qnil;
2113
- parent_id_val = Qnil;
2114
- }
2115
-
2116
- // Get rule ID and increment
2117
- int rule_id = ctx->rule_id_counter++;
2118
-
2119
- // Determine selector_list_id value
2120
- VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
2121
-
2122
- // Deep copy declarations for selector lists to avoid shared state
2123
- // (principle of least surprise - modifying one rule shouldn't affect others)
2124
- VALUE rule_declarations;
2125
- if (list_id >= 0) {
2126
- // Deep copy: both array and Declaration structs inside
2127
- long decl_count = RARRAY_LEN(declarations);
2128
- rule_declarations = rb_ary_new_capa(decl_count);
2129
- for (long k = 0; k < decl_count; k++) {
2130
- VALUE decl = rb_ary_entry(declarations, k);
2131
- VALUE new_decl = rb_struct_new(cDeclaration,
2132
- rb_struct_aref(decl, INT2FIX(DECL_PROPERTY)),
2133
- rb_struct_aref(decl, INT2FIX(DECL_VALUE)),
2134
- rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT))
2135
- );
2136
- rb_ary_push(rule_declarations, new_decl);
2137
- }
2138
- } else {
2139
- rule_declarations = rb_ary_dup(declarations);
2140
- }
2141
-
2142
- // Create Rule
2143
- VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2144
- VALUE rule = rb_struct_new(cRule,
2145
- INT2FIX(rule_id),
2146
- resolved_selector,
2147
- rule_declarations,
2148
- Qnil, // specificity
2149
- parent_id_val,
2150
- nesting_style_val,
2151
- selector_list_id_val,
2152
- media_query_id_val // media_query_id from parent context
2153
- );
2154
-
2155
- // Track rule in selector list if applicable
2156
- if (list_id >= 0) {
2157
- rb_ary_push(rule_ids_array, INT2FIX(rule_id));
2158
- }
2159
-
2160
- // Mark that we have nesting (only set once)
2161
- if (!ctx->has_nesting && !NIL_P(parent_id_val)) {
2162
- ctx->has_nesting = 1;
2163
- }
2164
-
2165
- rb_ary_push(ctx->rules_array, rule);
2166
-
2167
- // Update media index
2168
- update_media_index(ctx, parent_media_sym, rule_id);
2169
- } else if (ctx->check_invalid_selector_syntax && selector_count > 1) {
2170
- // Empty selector in comma-separated list (e.g., "h1, , h3")
2171
- raise_parse_error_at(ctx, seg_start, "Invalid selector syntax: empty selector in comma-separated list", "invalid_selector_syntax");
2172
- }
2173
-
2174
- seg_start = seg + 1;
2175
- }
2176
- seg++;
2177
- }
2178
- } else {
2179
- // NESTED PATH: Parse mixed declarations + nested rules
2180
- // For each comma-separated parent selector, parse the block with that parent
2181
- //
2182
- // Example: ".a, .b { color: red; & .child { font: 14px; } }"
2183
- // ^selector_start ^sel_end
2184
- // Creates:
2185
- // - .a with declarations [color: red]
2186
- // - .a .child with declarations [font: 14px]
2187
- // - .b with declarations [color: red]
2188
- // - .b .child with declarations [font: 14px]
2189
-
2190
- // Count selectors for selector list tracking
2191
- int selector_count = 1;
2192
- if (ctx->selector_lists_enabled) {
2193
- const char *count_ptr = selector_start;
2194
- while (count_ptr < sel_end) {
2195
- if (*count_ptr == ',') {
2196
- selector_count++;
2197
- }
2198
- count_ptr++;
2199
- }
2200
- }
2201
-
2202
- // Create selector list if enabled and multiple selectors
2203
- int list_id = -1;
2204
- VALUE rule_ids_array = Qnil;
2205
- if (ctx->selector_lists_enabled && selector_count > 1) {
2206
- list_id = ctx->next_selector_list_id++;
2207
- rule_ids_array = rb_ary_new();
2208
- rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
2209
- }
2210
-
2211
- const char *seg_start = selector_start;
2212
- const char *seg = selector_start;
2213
-
2214
- while (seg <= sel_end) {
2215
- if (seg == sel_end || *seg == ',') { // At: ',' or end
2216
- // Trim segment
2217
- while (seg_start < seg && IS_WHITESPACE(*seg_start)) {
2218
- seg_start++;
2219
- }
2220
-
2221
- const char *seg_end_ptr = seg;
2222
- while (seg_end_ptr > seg_start && IS_WHITESPACE(*(seg_end_ptr - 1))) {
2223
- seg_end_ptr--;
2224
- }
2225
-
2226
- if (seg_end_ptr > seg_start) {
2227
- VALUE current_selector = rb_utf8_str_new(seg_start, seg_end_ptr - seg_start);
2228
-
2229
- // Resolve against parent if we're already nested
2230
- VALUE resolved_current;
2231
- VALUE current_nesting_style;
2232
- VALUE current_parent_id;
2233
-
2234
- if (!NIL_P(parent_selector)) {
2235
- VALUE result = resolve_nested_selector(parent_selector, RSTRING_PTR(current_selector), RSTRING_LEN(current_selector));
2236
- resolved_current = rb_ary_entry(result, 0);
2237
- current_nesting_style = rb_ary_entry(result, 1);
2238
- current_parent_id = parent_rule_id;
2239
- } else {
2240
- resolved_current = current_selector;
2241
- current_nesting_style = Qnil;
2242
- current_parent_id = Qnil;
2243
- }
2244
-
2245
- // Get rule ID for current selector (increment to reserve it)
2246
- int current_rule_id = ctx->rule_id_counter++;
2247
-
2248
- // Reserve parent's position in rules array with placeholder
2249
- // This ensures parent comes before nested rules in array order (per W3C spec)
2250
- long parent_position = RARRAY_LEN(ctx->rules_array);
2251
- rb_ary_push(ctx->rules_array, Qnil);
2252
-
2253
- // Parse mixed block (declarations + nested selectors)
2254
- // Nested rules will be added AFTER the placeholder
2255
- ctx->depth++;
2256
- VALUE parent_declarations = parse_mixed_block(ctx, decl_start, p,
2257
- resolved_current, INT2FIX(current_rule_id), parent_media_sym, parent_media_query_id);
2258
- ctx->depth--;
2259
-
2260
- // Determine selector_list_id value
2261
- VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
2262
-
2263
- // Create parent rule and replace placeholder
2264
- // Always create the rule (even if empty) to avoid edge cases
2265
- VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2266
- VALUE rule = rb_struct_new(cRule,
2267
- INT2FIX(current_rule_id),
2268
- resolved_current,
2269
- parent_declarations,
2270
- Qnil, // specificity
2271
- current_parent_id,
2272
- current_nesting_style,
2273
- selector_list_id_val,
2274
- media_query_id_val // media_query_id from parent context
2275
- );
2276
-
2277
- // Track rule in selector list if applicable
2278
- if (list_id >= 0) {
2279
- rb_ary_push(rule_ids_array, INT2FIX(current_rule_id));
2280
- }
2281
-
2282
- // Mark that we have nesting (only set once)
2283
- if (!ctx->has_nesting && !NIL_P(current_parent_id)) {
2284
- ctx->has_nesting = 1;
2285
- }
2286
-
2287
- // Replace placeholder with actual rule - just pointer assignment, fast!
2288
- rb_ary_store(ctx->rules_array, parent_position, rule);
2289
- update_media_index(ctx, parent_media_sym, current_rule_id);
2290
- }
2291
-
2292
- seg_start = seg + 1;
2293
- }
2294
- seg++;
2295
- }
2296
- }
2297
-
2291
+ finish_rule_block(ctx, selector_start, decl_start, p,
2292
+ parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id);
2298
2293
  selector_start = NULL;
2299
2294
  decl_start = NULL;
2300
2295
  }
@@ -2434,7 +2429,6 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2434
2429
  ctx.media_query_id_counter = 0; // Start from 0
2435
2430
  ctx.next_media_query_list_id = 0; // Start from 0
2436
2431
  ctx.media_query_count = 0;
2437
- ctx.media_cache = NULL; // Removed - no perf benefit
2438
2432
  ctx.has_nesting = 0; // Will be set to 1 if any nested rules are created
2439
2433
  ctx.selector_lists_enabled = selector_lists_enabled;
2440
2434
  ctx.depth = 0; // Start at depth 0