cataract 0.2.1 → 0.2.2

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.
@@ -17,11 +17,14 @@
17
17
  typedef struct {
18
18
  VALUE rules_array; // Array of Rule structs
19
19
  VALUE media_index; // Hash: Symbol => Array of rule IDs
20
+ VALUE selector_lists; // Hash: list_id => Array of rule IDs
20
21
  VALUE imports_array; // Array of ImportStatement structs
21
22
  int rule_id_counter; // Next rule ID (0-indexed)
23
+ int next_selector_list_id; // Next selector list ID (0-indexed)
22
24
  int media_query_count; // Safety limit for media queries
23
25
  st_table *media_cache; // Parse-time cache: string => parsed media types
24
26
  int has_nesting; // Set to 1 if any nested rules are created
27
+ int selector_lists_enabled; // Parser option: track selector lists (1=enabled, 0=disabled)
25
28
  int depth; // Current recursion depth (safety limit)
26
29
  } ParserContext;
27
30
 
@@ -499,9 +502,15 @@ static VALUE parse_declarations(const char *start, const char *end) {
499
502
  val_len, MAX_PROPERTY_VALUE_LENGTH);
500
503
  }
501
504
 
502
- // Create property string and lowercase it
503
- VALUE property_raw = rb_usascii_str_new(prop_start, prop_len);
504
- VALUE property = lowercase_property(property_raw);
505
+ // Create property string - use UTF-8 to support custom properties with Unicode
506
+ VALUE property = rb_utf8_str_new(prop_start, prop_len);
507
+ // Custom properties (--foo) are case-sensitive and can contain Unicode
508
+ // Regular properties are ASCII-only and case-insensitive
509
+ if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
510
+ // Regular property: force ASCII encoding and lowercase
511
+ rb_enc_associate(property, rb_usascii_encoding());
512
+ property = lowercase_property(property);
513
+ }
505
514
  VALUE value = rb_utf8_str_new(val_start, val_len);
506
515
 
507
516
  // Create Declaration struct
@@ -691,7 +700,8 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
691
700
  media_declarations,
692
701
  Qnil, // specificity
693
702
  parent_rule_id, // Link to parent for nested @media serialization
694
- Qnil // nesting_style (nil for @media nesting)
703
+ Qnil, // nesting_style (nil for @media nesting)
704
+ Qnil // selector_list_id
695
705
  );
696
706
 
697
707
  // Mark that we have nesting (only set once)
@@ -773,7 +783,8 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
773
783
  nested_declarations,
774
784
  Qnil, // specificity
775
785
  parent_rule_id,
776
- nesting_style
786
+ nesting_style,
787
+ Qnil // selector_list_id
777
788
  );
778
789
 
779
790
  // Mark that we have nesting (only set once)
@@ -852,8 +863,15 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
852
863
  val_len, MAX_PROPERTY_VALUE_LENGTH);
853
864
  }
854
865
 
855
- VALUE property_raw = rb_usascii_str_new(prop_start, prop_len);
856
- VALUE property = lowercase_property(property_raw);
866
+ // Create property string - use UTF-8 to support custom properties with Unicode
867
+ VALUE property = rb_utf8_str_new(prop_start, prop_len);
868
+ // Custom properties (--foo) are case-sensitive and can contain Unicode
869
+ // Regular properties are ASCII-only and case-insensitive
870
+ if (!(prop_len >= 2 && prop_start[0] == '-' && prop_start[1] == '-')) {
871
+ // Regular property: force ASCII encoding and lowercase
872
+ rb_enc_associate(property, rb_usascii_encoding());
873
+ property = lowercase_property(property);
874
+ }
857
875
  VALUE value = rb_utf8_str_new(val_start, val_len);
858
876
 
859
877
  VALUE decl = rb_struct_new(cDeclaration,
@@ -1158,10 +1176,14 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1158
1176
  ParserContext nested_ctx = {
1159
1177
  .rules_array = rb_ary_new(),
1160
1178
  .media_index = rb_hash_new(),
1179
+ .selector_lists = rb_hash_new(),
1180
+ .imports_array = rb_ary_new(),
1161
1181
  .rule_id_counter = 0,
1182
+ .next_selector_list_id = 0,
1162
1183
  .media_query_count = 0,
1163
1184
  .media_cache = NULL,
1164
1185
  .has_nesting = 0,
1186
+ .selector_lists_enabled = ctx->selector_lists_enabled,
1165
1187
  .depth = 0
1166
1188
  };
1167
1189
  parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID);
@@ -1283,6 +1305,28 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1283
1305
  // Example: ".a, .b, .c { color: red; }" creates 3 separate rules
1284
1306
  // ^selector_start ^sel_end
1285
1307
  // ^seg_start=seg (scanning for commas)
1308
+
1309
+ // Count selectors for selector list tracking
1310
+ int selector_count = 1;
1311
+ if (ctx->selector_lists_enabled) {
1312
+ const char *count_ptr = selector_start;
1313
+ while (count_ptr < sel_end) {
1314
+ if (*count_ptr == ',') {
1315
+ selector_count++;
1316
+ }
1317
+ count_ptr++;
1318
+ }
1319
+ }
1320
+
1321
+ // Create selector list if enabled and multiple selectors
1322
+ int list_id = -1;
1323
+ VALUE rule_ids_array = Qnil;
1324
+ if (ctx->selector_lists_enabled && selector_count > 1) {
1325
+ list_id = ctx->next_selector_list_id++;
1326
+ rule_ids_array = rb_ary_new();
1327
+ rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
1328
+ }
1329
+
1286
1330
  const char *seg_start = selector_start;
1287
1331
  const char *seg = selector_start;
1288
1332
 
@@ -1322,16 +1366,45 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1322
1366
  // Get rule ID and increment
1323
1367
  int rule_id = ctx->rule_id_counter++;
1324
1368
 
1369
+ // Determine selector_list_id value
1370
+ VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
1371
+
1372
+ // Deep copy declarations for selector lists to avoid shared state
1373
+ // (principle of least surprise - modifying one rule shouldn't affect others)
1374
+ VALUE rule_declarations;
1375
+ if (list_id >= 0) {
1376
+ // Deep copy: both array and Declaration structs inside
1377
+ long decl_count = RARRAY_LEN(declarations);
1378
+ rule_declarations = rb_ary_new_capa(decl_count);
1379
+ for (long k = 0; k < decl_count; k++) {
1380
+ VALUE decl = rb_ary_entry(declarations, k);
1381
+ VALUE new_decl = rb_struct_new(cDeclaration,
1382
+ rb_struct_aref(decl, INT2FIX(DECL_PROPERTY)),
1383
+ rb_struct_aref(decl, INT2FIX(DECL_VALUE)),
1384
+ rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT))
1385
+ );
1386
+ rb_ary_push(rule_declarations, new_decl);
1387
+ }
1388
+ } else {
1389
+ rule_declarations = rb_ary_dup(declarations);
1390
+ }
1391
+
1325
1392
  // Create Rule
1326
1393
  VALUE rule = rb_struct_new(cRule,
1327
1394
  INT2FIX(rule_id),
1328
1395
  resolved_selector,
1329
- rb_ary_dup(declarations),
1396
+ rule_declarations,
1330
1397
  Qnil, // specificity
1331
1398
  parent_id_val,
1332
- nesting_style_val
1399
+ nesting_style_val,
1400
+ selector_list_id_val
1333
1401
  );
1334
1402
 
1403
+ // Track rule in selector list if applicable
1404
+ if (list_id >= 0) {
1405
+ rb_ary_push(rule_ids_array, INT2FIX(rule_id));
1406
+ }
1407
+
1335
1408
  // Mark that we have nesting (only set once)
1336
1409
  if (!ctx->has_nesting && !NIL_P(parent_id_val)) {
1337
1410
  ctx->has_nesting = 1;
@@ -1358,6 +1431,28 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1358
1431
  // - .a .child with declarations [font: 14px]
1359
1432
  // - .b with declarations [color: red]
1360
1433
  // - .b .child with declarations [font: 14px]
1434
+
1435
+ // Count selectors for selector list tracking
1436
+ int selector_count = 1;
1437
+ if (ctx->selector_lists_enabled) {
1438
+ const char *count_ptr = selector_start;
1439
+ while (count_ptr < sel_end) {
1440
+ if (*count_ptr == ',') {
1441
+ selector_count++;
1442
+ }
1443
+ count_ptr++;
1444
+ }
1445
+ }
1446
+
1447
+ // Create selector list if enabled and multiple selectors
1448
+ int list_id = -1;
1449
+ VALUE rule_ids_array = Qnil;
1450
+ if (ctx->selector_lists_enabled && selector_count > 1) {
1451
+ list_id = ctx->next_selector_list_id++;
1452
+ rule_ids_array = rb_ary_new();
1453
+ rb_hash_aset(ctx->selector_lists, INT2FIX(list_id), rule_ids_array);
1454
+ }
1455
+
1361
1456
  const char *seg_start = selector_start;
1362
1457
  const char *seg = selector_start;
1363
1458
 
@@ -1407,6 +1502,9 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1407
1502
  resolved_current, INT2FIX(current_rule_id), parent_media_sym);
1408
1503
  ctx->depth--;
1409
1504
 
1505
+ // Determine selector_list_id value
1506
+ VALUE selector_list_id_val = (list_id >= 0) ? INT2FIX(list_id) : Qnil;
1507
+
1410
1508
  // Create parent rule and replace placeholder
1411
1509
  // Always create the rule (even if empty) to avoid edge cases
1412
1510
  VALUE rule = rb_struct_new(cRule,
@@ -1415,9 +1513,15 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
1415
1513
  parent_declarations,
1416
1514
  Qnil, // specificity
1417
1515
  current_parent_id,
1418
- current_nesting_style
1516
+ current_nesting_style,
1517
+ selector_list_id_val
1419
1518
  );
1420
1519
 
1520
+ // Track rule in selector list if applicable
1521
+ if (list_id >= 0) {
1522
+ rb_ary_push(rule_ids_array, INT2FIX(current_rule_id));
1523
+ }
1524
+
1421
1525
  // Mark that we have nesting (only set once)
1422
1526
  if (!ctx->has_nesting && !NIL_P(current_parent_id)) {
1423
1527
  ctx->has_nesting = 1;
@@ -1473,12 +1577,17 @@ VALUE parse_media_types(VALUE self, VALUE media_query_sym) {
1473
1577
  * Main parse entry point
1474
1578
  * Returns: { rules: [...], media_index: {...}, charset: "..." | nil, last_rule_id: N }
1475
1579
  */
1476
- VALUE parse_css_new_impl(VALUE css_string, int rule_id_offset) {
1580
+ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_offset) {
1477
1581
  Check_Type(css_string, T_STRING);
1582
+ Check_Type(parser_options, T_HASH);
1478
1583
 
1479
1584
  DEBUG_PRINTF("\n[PARSE_NEW] ========== NEW PARSE CALL ==========\n");
1480
1585
  DEBUG_PRINTF("[PARSE_NEW] Input CSS (first 100 chars): %.100s\n", RSTRING_PTR(css_string));
1481
1586
 
1587
+ // Read parser options
1588
+ VALUE selector_lists_opt = rb_hash_aref(parser_options, ID2SYM(rb_intern("selector_lists")));
1589
+ int selector_lists_enabled = (NIL_P(selector_lists_opt) || RTEST(selector_lists_opt)) ? 1 : 0;
1590
+
1482
1591
  const char *css = RSTRING_PTR(css_string);
1483
1592
  const char *pe = css + RSTRING_LEN(css_string);
1484
1593
  const char *p = css;
@@ -1513,11 +1622,14 @@ VALUE parse_css_new_impl(VALUE css_string, int rule_id_offset) {
1513
1622
  ParserContext ctx;
1514
1623
  ctx.rules_array = rb_ary_new();
1515
1624
  ctx.media_index = rb_hash_new();
1625
+ ctx.selector_lists = rb_hash_new();
1516
1626
  ctx.imports_array = rb_ary_new();
1517
1627
  ctx.rule_id_counter = rule_id_offset; // Start from offset
1628
+ ctx.next_selector_list_id = 0; // Start from 0
1518
1629
  ctx.media_query_count = 0;
1519
1630
  ctx.media_cache = NULL; // Removed - no perf benefit
1520
1631
  ctx.has_nesting = 0; // Will be set to 1 if any nested rules are created
1632
+ ctx.selector_lists_enabled = selector_lists_enabled;
1521
1633
  ctx.depth = 0; // Start at depth 0
1522
1634
 
1523
1635
  // Parse CSS (top-level, no parent context)
@@ -1528,6 +1640,7 @@ VALUE parse_css_new_impl(VALUE css_string, int rule_id_offset) {
1528
1640
  VALUE result = rb_hash_new();
1529
1641
  rb_hash_aset(result, ID2SYM(rb_intern("rules")), ctx.rules_array);
1530
1642
  rb_hash_aset(result, ID2SYM(rb_intern("_media_index")), ctx.media_index);
1643
+ rb_hash_aset(result, ID2SYM(rb_intern("_selector_lists")), ctx.selector_lists);
1531
1644
  rb_hash_aset(result, ID2SYM(rb_intern("imports")), ctx.imports_array);
1532
1645
  rb_hash_aset(result, ID2SYM(rb_intern("charset")), charset);
1533
1646
  rb_hash_aset(result, ID2SYM(rb_intern("last_rule_id")), INT2FIX(ctx.rule_id_counter));
@@ -1536,6 +1649,7 @@ VALUE parse_css_new_impl(VALUE css_string, int rule_id_offset) {
1536
1649
  RB_GC_GUARD(charset);
1537
1650
  RB_GC_GUARD(ctx.rules_array);
1538
1651
  RB_GC_GUARD(ctx.media_index);
1652
+ RB_GC_GUARD(ctx.selector_lists);
1539
1653
  RB_GC_GUARD(ctx.imports_array);
1540
1654
  RB_GC_GUARD(result);
1541
1655
 
@@ -623,7 +623,7 @@ struct flatten_selectors_context {
623
623
  };
624
624
 
625
625
  // Forward declaration
626
- static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector);
626
+ static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector, VALUE *out_selector_list_id);
627
627
 
628
628
  // Callback for rb_hash_foreach when merging selector groups
629
629
  static int flatten_selector_group_callback(VALUE selector, VALUE group_indices, VALUE arg) {
@@ -634,8 +634,9 @@ static int flatten_selector_group_callback(VALUE selector, VALUE group_indices,
634
634
  ctx->selector_index, ctx->total_selectors,
635
635
  RSTRING_PTR(selector), RARRAY_LEN(group_indices));
636
636
 
637
- // Merge all rules in this selector group
638
- VALUE merged_decls = flatten_rules_for_selector(ctx->rules_array, group_indices, selector);
637
+ // Merge all rules in this selector group and preserve selector_list_id if all rules share same ID
638
+ VALUE selector_list_id = Qnil;
639
+ VALUE merged_decls = flatten_rules_for_selector(ctx->rules_array, group_indices, selector, &selector_list_id);
639
640
 
640
641
  // Create new rule with this selector and merged declarations
641
642
  VALUE new_rule = rb_struct_new(cRule,
@@ -644,7 +645,8 @@ static int flatten_selector_group_callback(VALUE selector, VALUE group_indices,
644
645
  merged_decls,
645
646
  Qnil, // specificity
646
647
  Qnil, // parent_rule_id
647
- Qnil // nesting_style
648
+ Qnil, // nesting_style
649
+ selector_list_id // Preserve selector_list_id if all rules in group share same ID
648
650
  );
649
651
  rb_ary_push(ctx->merged_rules, new_rule);
650
652
 
@@ -657,15 +659,66 @@ static int flatten_selector_group_callback(VALUE selector, VALUE group_indices,
657
659
  * Takes an array of rule indices that all share the same selector,
658
660
  * expands shorthands, applies cascade rules, and recreates shorthands.
659
661
  *
662
+ * @param out_selector_list_id Output parameter: set to selector_list_id if all rules share same ID, else Qnil
660
663
  * Returns: Array of merged Declaration structs
661
664
  */
662
- static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector) {
665
+ static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector, VALUE *out_selector_list_id) {
663
666
  long num_rules_in_group = RARRAY_LEN(rule_indices);
664
667
  VALUE properties_hash = rb_hash_new();
665
668
 
666
669
  DEBUG_PRINTF(" [flatten_rules_for_selector] Merging %ld rules for selector '%s'\n",
667
670
  num_rules_in_group, RSTRING_PTR(selector));
668
671
 
672
+ // Extract selector_list_id from rules - preserve if all rules share same ID
673
+ VALUE first_selector_list_id = Qnil;
674
+ int all_same_selector_list_id = 1;
675
+
676
+ DEBUG_PRINTF(" Checking if rules share same selector_list_id...\n");
677
+
678
+ for (long g = 0; g < num_rules_in_group; g++) {
679
+ long rule_idx = FIX2LONG(rb_ary_entry(rule_indices, g));
680
+ VALUE rule = RARRAY_AREF(rules_array, rule_idx);
681
+
682
+ // Skip AtRule objects - they don't have selector_list_id
683
+ if (rb_obj_is_kind_of(rule, cAtRule)) {
684
+ continue;
685
+ }
686
+
687
+ VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
688
+
689
+ if (NIL_P(selector_list_id)) {
690
+ DEBUG_PRINTF(" Rule %ld: has nil selector_list_id, can't preserve\n", g);
691
+ // If any rule has nil selector_list_id, can't preserve
692
+ all_same_selector_list_id = 0;
693
+ break;
694
+ }
695
+
696
+ if (g == 0 || NIL_P(first_selector_list_id)) {
697
+ first_selector_list_id = selector_list_id;
698
+ DEBUG_PRINTF(" Rule %ld: first selector_list_id=%ld\n", g, NUM2LONG(first_selector_list_id));
699
+ } else if (!rb_equal(first_selector_list_id, selector_list_id)) {
700
+ DEBUG_PRINTF(" Rule %ld: different selector_list_id=%ld (vs %ld), can't preserve\n",
701
+ g, NUM2LONG(selector_list_id), NUM2LONG(first_selector_list_id));
702
+ // Different selector_list_ids - can't preserve
703
+ all_same_selector_list_id = 0;
704
+ break;
705
+ } else {
706
+ DEBUG_PRINTF(" Rule %ld: same selector_list_id=%ld\n", g, NUM2LONG(selector_list_id));
707
+ }
708
+ }
709
+
710
+ // Set output parameter: preserve selector_list_id only if all rules share same ID
711
+ if (out_selector_list_id) {
712
+ *out_selector_list_id = (all_same_selector_list_id && !NIL_P(first_selector_list_id)) ? first_selector_list_id : Qnil;
713
+ #ifdef CATARACT_DEBUG
714
+ if (!NIL_P(*out_selector_list_id)) {
715
+ DEBUG_PRINTF(" -> Preserving selector_list_id=%ld for merged rule\n", NUM2LONG(*out_selector_list_id));
716
+ } else {
717
+ DEBUG_PRINTF(" -> NOT preserving selector_list_id (not all same)\n");
718
+ }
719
+ #endif
720
+ }
721
+
669
722
  // Process each rule in this selector group
670
723
  for (long g = 0; g < num_rules_in_group; g++) {
671
724
  long rule_idx = FIX2LONG(rb_ary_entry(rule_indices, g));
@@ -1015,6 +1068,178 @@ static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, V
1015
1068
  return merged_decls;
1016
1069
  }
1017
1070
 
1071
+ /*
1072
+ * Helper function: Check if two declaration arrays are equal
1073
+ *
1074
+ * Returns: true if declarations have same properties, values, and importance
1075
+ */
1076
+ static int declarations_equal(VALUE decls1, VALUE decls2) {
1077
+ long len1 = RARRAY_LEN(decls1);
1078
+ long len2 = RARRAY_LEN(decls2);
1079
+
1080
+ DEBUG_PRINTF(" [declarations_equal] Comparing %ld vs %ld declarations\n", len1, len2);
1081
+
1082
+ if (len1 != len2) {
1083
+ DEBUG_PRINTF(" -> Different lengths, NOT equal\n");
1084
+ return 0;
1085
+ }
1086
+
1087
+ // Compare each declaration (property, value, important must all match)
1088
+ for (long i = 0; i < len1; i++) {
1089
+ VALUE d1 = RARRAY_AREF(decls1, i);
1090
+ VALUE d2 = RARRAY_AREF(decls2, i);
1091
+
1092
+ VALUE prop1 = rb_struct_aref(d1, INT2FIX(DECL_PROPERTY));
1093
+ VALUE prop2 = rb_struct_aref(d2, INT2FIX(DECL_PROPERTY));
1094
+ VALUE val1 = rb_struct_aref(d1, INT2FIX(DECL_VALUE));
1095
+ VALUE val2 = rb_struct_aref(d2, INT2FIX(DECL_VALUE));
1096
+ VALUE imp1 = rb_struct_aref(d1, INT2FIX(DECL_IMPORTANT));
1097
+ VALUE imp2 = rb_struct_aref(d2, INT2FIX(DECL_IMPORTANT));
1098
+
1099
+ if (!rb_equal(prop1, prop2) || !rb_equal(val1, val2) || (RTEST(imp1) != RTEST(imp2))) {
1100
+ DEBUG_PRINTF(" -> Decl %ld differs: %s:%s%s vs %s:%s%s\n",
1101
+ i,
1102
+ RSTRING_PTR(prop1), RSTRING_PTR(val1), RTEST(imp1) ? "!" : "",
1103
+ RSTRING_PTR(prop2), RSTRING_PTR(val2), RTEST(imp2) ? "!" : "");
1104
+ // Protect VALUEs from GC after rb_equal() calls and before RSTRING_PTR usage above
1105
+ RB_GC_GUARD(prop1);
1106
+ RB_GC_GUARD(val1);
1107
+ RB_GC_GUARD(prop2);
1108
+ RB_GC_GUARD(val2);
1109
+ return 0;
1110
+ }
1111
+ }
1112
+
1113
+ DEBUG_PRINTF(" -> All declarations match, equal\n");
1114
+ return 1;
1115
+ }
1116
+
1117
+ /*
1118
+ * Update selector lists to remove diverged rules
1119
+ *
1120
+ * After flattening/cascade, rules that were in the same selector list may have
1121
+ * different declarations. This function builds the selector_lists hash with only
1122
+ * rules that still match, and clears selector_list_id for diverged rules.
1123
+ *
1124
+ * @param merged_rules Array of flattened rules (with new IDs assigned)
1125
+ * @param selector_lists Empty hash to populate with list_id => Array of rule IDs
1126
+ */
1127
+ static void update_selector_lists_for_divergence(VALUE merged_rules, VALUE selector_lists) {
1128
+ DEBUG_PRINTF("\n=== update_selector_lists_for_divergence ===\n");
1129
+
1130
+ // Group merged rules by selector_list_id (skip rules with no list)
1131
+ // NOTE: Using manual iteration instead of group_by to avoid Ruby method calls
1132
+ VALUE rules_by_list = rb_hash_new();
1133
+
1134
+ long num_rules = RARRAY_LEN(merged_rules);
1135
+ DEBUG_PRINTF(" Total merged rules: %ld\n", num_rules);
1136
+
1137
+ for (long i = 0; i < num_rules; i++) {
1138
+ VALUE rule = RARRAY_AREF(merged_rules, i);
1139
+
1140
+ // Skip AtRule objects
1141
+ if (rb_obj_is_kind_of(rule, cAtRule)) {
1142
+ continue;
1143
+ }
1144
+
1145
+ VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
1146
+ #ifdef CATARACT_DEBUG
1147
+ VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1148
+ #endif
1149
+
1150
+ if (NIL_P(selector_list_id)) {
1151
+ DEBUG_PRINTF(" Rule %ld (%s): no selector_list_id\n", i, RSTRING_PTR(selector));
1152
+ continue;
1153
+ }
1154
+
1155
+ DEBUG_PRINTF(" Rule %ld (%s): selector_list_id=%ld\n",
1156
+ i, RSTRING_PTR(selector), NUM2LONG(selector_list_id));
1157
+
1158
+ VALUE group = rb_hash_aref(rules_by_list, selector_list_id);
1159
+ if (NIL_P(group)) {
1160
+ group = rb_ary_new();
1161
+ rb_hash_aset(rules_by_list, selector_list_id, group);
1162
+ DEBUG_PRINTF(" -> Created new group for list_id=%ld\n", NUM2LONG(selector_list_id));
1163
+ }
1164
+ rb_ary_push(group, rule);
1165
+ }
1166
+
1167
+ // For each selector list, check if declarations still match
1168
+ VALUE list_ids = rb_funcall(rules_by_list, rb_intern("keys"), 0);
1169
+ long num_lists = RARRAY_LEN(list_ids);
1170
+ DEBUG_PRINTF(" Found %ld selector list groups to check\n", num_lists);
1171
+
1172
+ for (long i = 0; i < num_lists; i++) {
1173
+ VALUE list_id = RARRAY_AREF(list_ids, i);
1174
+ VALUE rules_in_list = rb_hash_aref(rules_by_list, list_id);
1175
+ long num_in_list = RARRAY_LEN(rules_in_list);
1176
+
1177
+ DEBUG_PRINTF("\n Checking list_id=%ld: %ld rules\n", NUM2LONG(list_id), num_in_list);
1178
+
1179
+ // Skip if only one rule in list (nothing to compare)
1180
+ if (num_in_list <= 1) {
1181
+ DEBUG_PRINTF(" -> Only 1 rule, skipping\n");
1182
+ continue;
1183
+ }
1184
+
1185
+ // Get first rule as reference
1186
+ VALUE reference_rule = RARRAY_AREF(rules_in_list, 0);
1187
+ VALUE reference_decls = rb_struct_aref(reference_rule, INT2FIX(RULE_DECLARATIONS));
1188
+ #ifdef CATARACT_DEBUG
1189
+ VALUE reference_selector = rb_struct_aref(reference_rule, INT2FIX(RULE_SELECTOR));
1190
+ DEBUG_PRINTF(" Reference rule: selector=%s, %ld declarations\n",
1191
+ RSTRING_PTR(reference_selector), RARRAY_LEN(reference_decls));
1192
+ #endif
1193
+
1194
+ // Find rules that still match (have identical declarations)
1195
+ VALUE matching_rules = rb_ary_new();
1196
+ rb_ary_push(matching_rules, reference_rule);
1197
+
1198
+ for (long j = 1; j < num_in_list; j++) {
1199
+ VALUE rule = RARRAY_AREF(rules_in_list, j);
1200
+ VALUE decls = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1201
+ #ifdef CATARACT_DEBUG
1202
+ VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1203
+ DEBUG_PRINTF(" Comparing rule %ld (selector=%s):\n", j, RSTRING_PTR(selector));
1204
+ #endif
1205
+
1206
+ if (declarations_equal(reference_decls, decls)) {
1207
+ DEBUG_PRINTF(" -> MATCHES reference, keeping in list\n");
1208
+ rb_ary_push(matching_rules, rule);
1209
+ } else {
1210
+ DEBUG_PRINTF(" -> DIVERGED from reference, clearing selector_list_id\n");
1211
+ // Clear selector_list_id for diverged rule
1212
+ rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1213
+ }
1214
+ }
1215
+
1216
+ // Only keep the selector list if at least 2 rules still match
1217
+ long num_matching = RARRAY_LEN(matching_rules);
1218
+ DEBUG_PRINTF(" Result: %ld/%ld rules still match\n", num_matching, num_in_list);
1219
+
1220
+ if (num_matching >= 2) {
1221
+ // Build selector_lists hash with NEW rule IDs
1222
+ VALUE rule_ids = rb_ary_new_capa(num_matching);
1223
+ for (long j = 0; j < num_matching; j++) {
1224
+ VALUE rule = RARRAY_AREF(matching_rules, j);
1225
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
1226
+ rb_ary_push(rule_ids, rule_id);
1227
+ }
1228
+ rb_hash_aset(selector_lists, list_id, rule_ids);
1229
+ DEBUG_PRINTF(" -> Keeping selector list with %ld rules\n", num_matching);
1230
+ } else {
1231
+ DEBUG_PRINTF(" -> Only 1 rule left, clearing selector_list_id for it too\n");
1232
+ // Clear selector_list_id for the last remaining rule too
1233
+ for (long j = 0; j < num_matching; j++) {
1234
+ VALUE rule = RARRAY_AREF(matching_rules, j);
1235
+ rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ DEBUG_PRINTF("\n=== End divergence tracking: %ld selector lists preserved ===\n\n", RHASH_SIZE(selector_lists));
1241
+ }
1242
+
1018
1243
  // Flatten CSS rules by applying cascade rules
1019
1244
  // Input: Stylesheet object or CSS string
1020
1245
  // Output: Stylesheet with flattened declarations (cascade applied)
@@ -1025,7 +1250,8 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1025
1250
  // Most calls pass Stylesheet (common case), String is rare
1026
1251
  if (TYPE(input) == T_STRING) {
1027
1252
  // Parse CSS string first
1028
- VALUE parsed = parse_css_new(self, input);
1253
+ VALUE argv[1] = { input };
1254
+ VALUE parsed = parse_css_new(1, argv, self);
1029
1255
  rules_array = rb_hash_aref(parsed, ID2SYM(rb_intern("rules")));
1030
1256
  } else if (rb_obj_is_kind_of(input, cStylesheet)) {
1031
1257
  // Extract @rules from Stylesheet (common case)
@@ -1214,10 +1440,8 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1214
1440
  VALUE passthrough_sheet = rb_class_new_instance(0, NULL, cStylesheet);
1215
1441
  rb_ivar_set(passthrough_sheet, id_ivar_rules, passthrough_rules);
1216
1442
 
1217
- // Set empty @media_index
1443
+ // Set empty @media_index (no media rules after flatten)
1218
1444
  VALUE media_idx = rb_hash_new();
1219
- VALUE all_ids = rb_ary_new();
1220
- rb_hash_aset(media_idx, ID2SYM(id_all), all_ids);
1221
1445
  rb_ivar_set(passthrough_sheet, id_ivar_media_index, media_idx);
1222
1446
 
1223
1447
  return passthrough_sheet;
@@ -1258,15 +1482,45 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1258
1482
 
1259
1483
  rb_ivar_set(merged_sheet, id_ivar_rules, merged_rules);
1260
1484
 
1261
- // Set @media_index with :all pointing to all rule IDs
1262
- VALUE media_idx = rb_hash_new();
1263
- VALUE all_ids = rb_ary_new();
1264
- for (int i = 0; i < rule_id_counter; i++) {
1265
- rb_ary_push(all_ids, INT2FIX(i));
1485
+ // Handle selector list divergence: remove rules from selector lists if declarations no longer match
1486
+ // This makes selector_list_id authoritative - if set, declarations MUST be identical
1487
+ // Only process if selector_lists is enabled in the stylesheet's parser options
1488
+ VALUE selector_lists = rb_hash_new();
1489
+ int selector_lists_enabled = 0;
1490
+
1491
+ if (rb_obj_is_kind_of(input, cStylesheet)) {
1492
+ VALUE parser_options = rb_ivar_get(input, rb_intern("@parser_options"));
1493
+
1494
+ if (!NIL_P(parser_options)) {
1495
+ VALUE enabled_val = rb_hash_aref(parser_options, ID2SYM(rb_intern("selector_lists")));
1496
+ selector_lists_enabled = RTEST(enabled_val);
1497
+
1498
+ if (selector_lists_enabled) {
1499
+ update_selector_lists_for_divergence(merged_rules, selector_lists);
1500
+ } else {
1501
+ // Clear all selector_list_ids when feature is disabled
1502
+ for (long i = 0; i < rule_id_counter; i++) {
1503
+ VALUE rule = RARRAY_AREF(merged_rules, i);
1504
+ if (!rb_obj_is_kind_of(rule, cAtRule)) {
1505
+ rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1506
+ }
1507
+ }
1508
+ }
1509
+ } else {
1510
+ // Default behavior when parser_options is nil: assume enabled
1511
+ update_selector_lists_for_divergence(merged_rules, selector_lists);
1512
+ }
1266
1513
  }
1267
- rb_hash_aset(media_idx, ID2SYM(id_all), all_ids);
1514
+
1515
+ // Set @media_index to empty hash (no media rules after flatten)
1516
+ // NOTE: Setting to empty hash instead of { all: [ids] } to match pure Ruby behavior
1517
+ // and avoid wrapping output in @media all during serialization
1518
+ VALUE media_idx = rb_hash_new();
1268
1519
  rb_ivar_set(merged_sheet, id_ivar_media_index, media_idx);
1269
1520
 
1521
+ // Set @_selector_lists with divergence tracking
1522
+ rb_ivar_set(merged_sheet, rb_intern("@_selector_lists"), selector_lists);
1523
+
1270
1524
  return merged_sheet;
1271
1525
  }
1272
1526
 
@@ -1731,7 +1985,8 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1731
1985
  merged_declarations, // declarations
1732
1986
  Qnil, // specificity (not applicable)
1733
1987
  Qnil, // parent_rule_id (not nested)
1734
- Qnil // nesting_style (not nested)
1988
+ Qnil, // nesting_style (not nested)
1989
+ Qnil // selector_list_id
1735
1990
  );
1736
1991
 
1737
1992
  // Set @rules array with single merged rule (use cached ID)
@@ -15,4 +15,23 @@ module Cataract
15
15
  # @attr [String] value CSS property value
16
16
  # @attr [Boolean] important Whether the declaration has !important
17
17
  Declaration = Struct.new(:property, :value, :important) unless const_defined?(:Declaration)
18
+
19
+ class Declaration
20
+ # Check if this declaration is a custom property (CSS variable).
21
+ #
22
+ # Custom properties are properties that start with the "--" prefix.
23
+ #
24
+ # @return [Boolean] true if property is a custom property
25
+ #
26
+ # @example Custom property
27
+ # decl = Cataract::Declaration.new('--color', 'red', false)
28
+ # decl.custom_property? #=> true
29
+ #
30
+ # @example Regular property
31
+ # decl = Cataract::Declaration.new('color', 'red', false)
32
+ # decl.custom_property? #=> false
33
+ def custom_property?
34
+ property.start_with?('--')
35
+ end
36
+ end
18
37
  end