cataract 0.1.1 → 0.1.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.
data/ext/cataract/merge.c CHANGED
@@ -333,6 +333,611 @@ void init_merge_constants(void) {
333
333
  } \
334
334
  } while(0)
335
335
 
336
+ // Helper macro: Recreate dimension shorthand (margin, padding, border-width)
337
+ // Takes a property prefix like "margin" and creates "margin" from margin-top/right/bottom/left
338
+ #define RECREATE_DIMENSION_SHORTHAND(hash, prefix, creator_func) \
339
+ do { \
340
+ char _top_name[64], _right_name[64], _bottom_name[64], _left_name[64]; \
341
+ snprintf(_top_name, sizeof(_top_name), "%s-top", prefix); \
342
+ snprintf(_right_name, sizeof(_right_name), "%s-right", prefix); \
343
+ snprintf(_bottom_name, sizeof(_bottom_name), "%s-bottom", prefix); \
344
+ snprintf(_left_name, sizeof(_left_name), "%s-left", prefix); \
345
+ \
346
+ VALUE _top_data = rb_hash_aref(hash, STR_NEW_CSTR(_top_name)); \
347
+ VALUE _right_data = rb_hash_aref(hash, STR_NEW_CSTR(_right_name)); \
348
+ VALUE _bottom_data = rb_hash_aref(hash, STR_NEW_CSTR(_bottom_name)); \
349
+ VALUE _left_data = rb_hash_aref(hash, STR_NEW_CSTR(_left_name)); \
350
+ \
351
+ if (!NIL_P(_top_data) && !NIL_P(_right_data) && !NIL_P(_bottom_data) && !NIL_P(_left_data)) { \
352
+ VALUE _top_imp = rb_hash_aref(_top_data, ID2SYM(id_important)); \
353
+ VALUE _right_imp = rb_hash_aref(_right_data, ID2SYM(id_important)); \
354
+ VALUE _bottom_imp = rb_hash_aref(_bottom_data, ID2SYM(id_important)); \
355
+ VALUE _left_imp = rb_hash_aref(_left_data, ID2SYM(id_important)); \
356
+ \
357
+ if (RTEST(_top_imp) == RTEST(_right_imp) && RTEST(_top_imp) == RTEST(_bottom_imp) && RTEST(_top_imp) == RTEST(_left_imp)) { \
358
+ VALUE _props = rb_hash_new(); \
359
+ rb_hash_aset(_props, STR_NEW_CSTR(_top_name), rb_hash_aref(_top_data, ID2SYM(id_value))); \
360
+ rb_hash_aset(_props, STR_NEW_CSTR(_right_name), rb_hash_aref(_right_data, ID2SYM(id_value))); \
361
+ rb_hash_aset(_props, STR_NEW_CSTR(_bottom_name), rb_hash_aref(_bottom_data, ID2SYM(id_value))); \
362
+ rb_hash_aset(_props, STR_NEW_CSTR(_left_name), rb_hash_aref(_left_data, ID2SYM(id_value))); \
363
+ \
364
+ VALUE _shorthand_value = creator_func(Qnil, _props); \
365
+ if (!NIL_P(_shorthand_value)) { \
366
+ VALUE _shorthand_data = rb_hash_new(); \
367
+ rb_hash_aset(_shorthand_data, ID2SYM(id_value), _shorthand_value); \
368
+ rb_hash_aset(_shorthand_data, ID2SYM(id_specificity), rb_hash_aref(_top_data, ID2SYM(id_specificity))); \
369
+ rb_hash_aset(_shorthand_data, ID2SYM(id_important), _top_imp); \
370
+ rb_hash_aset(hash, rb_usascii_str_new(prefix, strlen(prefix)), _shorthand_data); \
371
+ \
372
+ rb_hash_delete(hash, STR_NEW_CSTR(_top_name)); \
373
+ rb_hash_delete(hash, STR_NEW_CSTR(_right_name)); \
374
+ rb_hash_delete(hash, STR_NEW_CSTR(_bottom_name)); \
375
+ rb_hash_delete(hash, STR_NEW_CSTR(_left_name)); \
376
+ DEBUG_PRINTF(" -> Recreated %s shorthand\n", prefix); \
377
+ } \
378
+ } \
379
+ } \
380
+ } while(0)
381
+
382
+ /*
383
+ * Helper struct: For processing expanded properties during merge
384
+ */
385
+ struct expand_property_data {
386
+ VALUE properties_hash; // Target hash to store properties
387
+ int specificity; // Specificity of the selector
388
+ int is_important; // Whether the original declaration was !important
389
+ long source_order; // Source order of the original declaration
390
+ };
391
+
392
+ /*
393
+ * Callback: Process each expanded property and apply cascade rules
394
+ */
395
+ static int process_expanded_property(VALUE prop_name, VALUE prop_value, VALUE arg) {
396
+ struct expand_property_data *data = (struct expand_property_data *)arg;
397
+ VALUE properties_hash = data->properties_hash;
398
+ int specificity = data->specificity;
399
+ int is_important = data->is_important;
400
+ long source_order = data->source_order;
401
+
402
+ DEBUG_PRINTF(" -> Processing expanded: %s: %s%s\n",
403
+ RSTRING_PTR(prop_name), RSTRING_PTR(prop_value),
404
+ is_important ? " !important" : "");
405
+
406
+ // Apply CSS cascade rules
407
+ VALUE existing = rb_hash_aref(properties_hash, prop_name);
408
+ if (NIL_P(existing)) {
409
+ DEBUG_PRINTF(" -> NEW property\n");
410
+ VALUE prop_data = rb_hash_new();
411
+ rb_hash_aset(prop_data, ID2SYM(id_value), prop_value);
412
+ rb_hash_aset(prop_data, ID2SYM(id_specificity), INT2NUM(specificity));
413
+ rb_hash_aset(prop_data, ID2SYM(id_important), is_important ? Qtrue : Qfalse);
414
+ rb_hash_aset(prop_data, ID2SYM(rb_intern("source_order")), LONG2NUM(source_order));
415
+ rb_hash_aset(properties_hash, prop_name, prop_data);
416
+ } else {
417
+ // Property exists - apply CSS cascade rules
418
+ VALUE existing_important = rb_hash_aref(existing, ID2SYM(id_important));
419
+ VALUE existing_source_order_val = rb_hash_aref(existing, ID2SYM(rb_intern("source_order")));
420
+
421
+ int existing_is_important = RTEST(existing_important);
422
+ long existing_source_order = NUM2LONG(existing_source_order_val);
423
+
424
+ DEBUG_PRINTF(" -> COLLISION: existing important=%d source_order=%ld, new important=%d source_order=%ld\n",
425
+ existing_is_important, existing_source_order, is_important, source_order);
426
+
427
+ int should_replace = 0;
428
+
429
+ // Apply CSS cascade rules:
430
+ // 1. !important always wins over non-!important
431
+ // 2. Higher specificity wins (same selector = same specificity, skip)
432
+ // 3. Later source order wins
433
+ if (is_important && !existing_is_important) {
434
+ // New declaration is !important, existing is not - replace
435
+ should_replace = 1;
436
+ DEBUG_PRINTF(" -> REPLACE (new is !important, existing is not)\n");
437
+ } else if (!is_important && existing_is_important) {
438
+ // Existing declaration is !important, new is not - keep existing
439
+ should_replace = 0;
440
+ DEBUG_PRINTF(" -> KEEP (existing is !important, new is not)\n");
441
+ } else {
442
+ // Same importance level - later source order wins
443
+ should_replace = source_order > existing_source_order;
444
+ DEBUG_PRINTF(" -> %s (same importance, %s source order)\n",
445
+ should_replace ? "REPLACE" : "KEEP",
446
+ should_replace ? "later" : "earlier");
447
+ }
448
+
449
+ if (should_replace) {
450
+ rb_hash_aset(existing, ID2SYM(id_value), prop_value);
451
+ rb_hash_aset(existing, ID2SYM(id_important), is_important ? Qtrue : Qfalse);
452
+ rb_hash_aset(existing, ID2SYM(rb_intern("source_order")), LONG2NUM(source_order));
453
+ }
454
+ }
455
+
456
+ return ST_CONTINUE;
457
+ }
458
+
459
+ /*
460
+ * Helper function: Merge multiple rules with the same selector
461
+ *
462
+ * Takes an array of rule indices that all share the same selector,
463
+ * expands shorthands, applies cascade rules, and recreates shorthands.
464
+ *
465
+ * Returns: Array of merged Declaration structs
466
+ */
467
+ static VALUE merge_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector) {
468
+ long num_rules_in_group = RARRAY_LEN(rule_indices);
469
+ VALUE properties_hash = rb_hash_new();
470
+
471
+ DEBUG_PRINTF(" [merge_rules_for_selector] Merging %ld rules for selector '%s'\n",
472
+ num_rules_in_group, RSTRING_PTR(selector));
473
+
474
+ // Calculate specificity once for this selector
475
+ VALUE specificity_val = calculate_specificity(Qnil, selector);
476
+ int specificity = NUM2INT(specificity_val);
477
+
478
+ // Process each rule in this selector group
479
+ for (long g = 0; g < num_rules_in_group; g++) {
480
+ long rule_idx = FIX2LONG(rb_ary_entry(rule_indices, g));
481
+ VALUE rule = RARRAY_AREF(rules_array, rule_idx);
482
+ VALUE rule_id_val = rb_struct_aref(rule, INT2FIX(RULE_ID));
483
+ long rule_id = NUM2LONG(rule_id_val);
484
+ VALUE declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
485
+ long num_decls = RARRAY_LEN(declarations);
486
+
487
+ DEBUG_PRINTF(" [Rule %ld/%ld] rule_id=%ld, %ld declarations\n",
488
+ g + 1, num_rules_in_group, rule_id, num_decls);
489
+
490
+ // Process each declaration
491
+ for (long j = 0; j < num_decls; j++) {
492
+ VALUE decl = RARRAY_AREF(declarations, j);
493
+ VALUE property = rb_struct_aref(decl, INT2FIX(DECL_PROPERTY));
494
+ VALUE value = rb_struct_aref(decl, INT2FIX(DECL_VALUE));
495
+ VALUE important = rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT));
496
+ int is_important = RTEST(important);
497
+
498
+ // Calculate source order
499
+ long source_order = rule_id * 1000 + j;
500
+
501
+ DEBUG_PRINTF(" [Decl %ld] %s: %s%s (source_order=%ld)\n",
502
+ j, RSTRING_PTR(property), RSTRING_PTR(value),
503
+ is_important ? " !important" : "", source_order);
504
+
505
+ // Expand shorthands (margin, padding, background, font, etc.)
506
+ // The expand functions return a hash of {property => value}
507
+ const char *prop_cstr = RSTRING_PTR(property);
508
+ VALUE expanded = Qnil;
509
+
510
+ if (strcmp(prop_cstr, "margin") == 0) {
511
+ expanded = cataract_expand_margin(Qnil, value);
512
+ DEBUG_PRINTF(" -> Expanding margin shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
513
+ } else if (strcmp(prop_cstr, "padding") == 0) {
514
+ expanded = cataract_expand_padding(Qnil, value);
515
+ DEBUG_PRINTF(" -> Expanding padding shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
516
+ } else if (strcmp(prop_cstr, "background") == 0) {
517
+ expanded = cataract_expand_background(Qnil, value);
518
+ DEBUG_PRINTF(" -> Expanding background shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
519
+ } else if (strcmp(prop_cstr, "font") == 0) {
520
+ expanded = cataract_expand_font(Qnil, value);
521
+ DEBUG_PRINTF(" -> Expanding font shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
522
+ } else if (strcmp(prop_cstr, "border") == 0) {
523
+ expanded = cataract_expand_border(Qnil, value);
524
+ DEBUG_PRINTF(" -> Expanding border shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
525
+ } else if (strcmp(prop_cstr, "border-color") == 0) {
526
+ expanded = cataract_expand_border_color(Qnil, value);
527
+ DEBUG_PRINTF(" -> Expanding border-color shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
528
+ } else if (strcmp(prop_cstr, "border-style") == 0) {
529
+ expanded = cataract_expand_border_style(Qnil, value);
530
+ DEBUG_PRINTF(" -> Expanding border-style shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
531
+ } else if (strcmp(prop_cstr, "border-width") == 0) {
532
+ expanded = cataract_expand_border_width(Qnil, value);
533
+ DEBUG_PRINTF(" -> Expanding border-width shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
534
+ } else if (strcmp(prop_cstr, "list-style") == 0) {
535
+ expanded = cataract_expand_list_style(Qnil, value);
536
+ DEBUG_PRINTF(" -> Expanding list-style shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
537
+ } else if (strcmp(prop_cstr, "border-top") == 0) {
538
+ expanded = cataract_expand_border_side(Qnil, STR_NEW_CSTR("top"), value);
539
+ DEBUG_PRINTF(" -> Expanding border-top shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
540
+ } else if (strcmp(prop_cstr, "border-right") == 0) {
541
+ expanded = cataract_expand_border_side(Qnil, STR_NEW_CSTR("right"), value);
542
+ DEBUG_PRINTF(" -> Expanding border-right shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
543
+ } else if (strcmp(prop_cstr, "border-bottom") == 0) {
544
+ expanded = cataract_expand_border_side(Qnil, STR_NEW_CSTR("bottom"), value);
545
+ DEBUG_PRINTF(" -> Expanding border-bottom shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
546
+ } else if (strcmp(prop_cstr, "border-left") == 0) {
547
+ expanded = cataract_expand_border_side(Qnil, STR_NEW_CSTR("left"), value);
548
+ DEBUG_PRINTF(" -> Expanding border-left shorthand (%ld longhands)\n", RHASH_SIZE(expanded));
549
+ }
550
+
551
+ // Process expanded properties or the original property
552
+ if (!NIL_P(expanded) && RHASH_SIZE(expanded) > 0) {
553
+ // Use rb_hash_foreach to iterate over expanded properties
554
+ struct expand_property_data expand_data = {
555
+ .properties_hash = properties_hash,
556
+ .specificity = specificity,
557
+ .is_important = is_important,
558
+ .source_order = source_order
559
+ };
560
+ rb_hash_foreach(expanded, process_expanded_property, (VALUE)&expand_data);
561
+ } else {
562
+ // No expansion - process the original property directly
563
+ struct expand_property_data expand_data = {
564
+ .properties_hash = properties_hash,
565
+ .specificity = specificity,
566
+ .is_important = is_important,
567
+ .source_order = source_order
568
+ };
569
+ process_expanded_property(property, value, (VALUE)&expand_data);
570
+ }
571
+ }
572
+ }
573
+
574
+ // Recreate shorthands where possible (reduces output size)
575
+ DEBUG_PRINTF(" [merge_rules_for_selector] Recreating shorthands...\n");
576
+
577
+ // Try to recreate margin shorthand (if all 4 sides present)
578
+ RECREATE_DIMENSION_SHORTHAND(properties_hash, "margin", cataract_create_margin_shorthand);
579
+
580
+ // Try to recreate padding shorthand (if all 4 sides present)
581
+ RECREATE_DIMENSION_SHORTHAND(properties_hash, "padding", cataract_create_padding_shorthand);
582
+
583
+ // Try to recreate border-width shorthand (if all 4 sides present)
584
+ {
585
+ VALUE top = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-top-width"));
586
+ VALUE right = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-right-width"));
587
+ VALUE bottom = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-bottom-width"));
588
+ VALUE left = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-left-width"));
589
+
590
+ if (!NIL_P(top) && !NIL_P(right) && !NIL_P(bottom) && !NIL_P(left)) {
591
+ VALUE top_imp = rb_hash_aref(top, ID2SYM(id_important));
592
+ VALUE right_imp = rb_hash_aref(right, ID2SYM(id_important));
593
+ VALUE bottom_imp = rb_hash_aref(bottom, ID2SYM(id_important));
594
+ VALUE left_imp = rb_hash_aref(left, ID2SYM(id_important));
595
+
596
+ if (RTEST(top_imp) == RTEST(right_imp) && RTEST(top_imp) == RTEST(bottom_imp) && RTEST(top_imp) == RTEST(left_imp)) {
597
+ VALUE props = rb_hash_new();
598
+ rb_hash_aset(props, STR_NEW_CSTR("border-top-width"), rb_hash_aref(top, ID2SYM(id_value)));
599
+ rb_hash_aset(props, STR_NEW_CSTR("border-right-width"), rb_hash_aref(right, ID2SYM(id_value)));
600
+ rb_hash_aset(props, STR_NEW_CSTR("border-bottom-width"), rb_hash_aref(bottom, ID2SYM(id_value)));
601
+ rb_hash_aset(props, STR_NEW_CSTR("border-left-width"), rb_hash_aref(left, ID2SYM(id_value)));
602
+
603
+ VALUE shorthand_value = cataract_create_border_width_shorthand(Qnil, props);
604
+ if (!NIL_P(shorthand_value)) {
605
+ VALUE shorthand_data = rb_hash_new();
606
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
607
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(top, ID2SYM(id_specificity)));
608
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), top_imp);
609
+ rb_hash_aset(properties_hash, USASCII_STR("border-width"), shorthand_data);
610
+
611
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-top-width"));
612
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-right-width"));
613
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-bottom-width"));
614
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-left-width"));
615
+ DEBUG_PRINTF(" -> Recreated border-width shorthand\n");
616
+ }
617
+ }
618
+ }
619
+ }
620
+
621
+ // Try to recreate border-style shorthand (if all 4 sides present)
622
+ {
623
+ VALUE top = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-top-style"));
624
+ VALUE right = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-right-style"));
625
+ VALUE bottom = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-bottom-style"));
626
+ VALUE left = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-left-style"));
627
+
628
+ if (!NIL_P(top) && !NIL_P(right) && !NIL_P(bottom) && !NIL_P(left)) {
629
+ VALUE top_imp = rb_hash_aref(top, ID2SYM(id_important));
630
+ VALUE right_imp = rb_hash_aref(right, ID2SYM(id_important));
631
+ VALUE bottom_imp = rb_hash_aref(bottom, ID2SYM(id_important));
632
+ VALUE left_imp = rb_hash_aref(left, ID2SYM(id_important));
633
+
634
+ if (RTEST(top_imp) == RTEST(right_imp) && RTEST(top_imp) == RTEST(bottom_imp) && RTEST(top_imp) == RTEST(left_imp)) {
635
+ VALUE props = rb_hash_new();
636
+ rb_hash_aset(props, STR_NEW_CSTR("border-top-style"), rb_hash_aref(top, ID2SYM(id_value)));
637
+ rb_hash_aset(props, STR_NEW_CSTR("border-right-style"), rb_hash_aref(right, ID2SYM(id_value)));
638
+ rb_hash_aset(props, STR_NEW_CSTR("border-bottom-style"), rb_hash_aref(bottom, ID2SYM(id_value)));
639
+ rb_hash_aset(props, STR_NEW_CSTR("border-left-style"), rb_hash_aref(left, ID2SYM(id_value)));
640
+
641
+ VALUE shorthand_value = cataract_create_border_style_shorthand(Qnil, props);
642
+ if (!NIL_P(shorthand_value)) {
643
+ VALUE shorthand_data = rb_hash_new();
644
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
645
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(top, ID2SYM(id_specificity)));
646
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), top_imp);
647
+ rb_hash_aset(properties_hash, USASCII_STR("border-style"), shorthand_data);
648
+
649
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-top-style"));
650
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-right-style"));
651
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-bottom-style"));
652
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-left-style"));
653
+ DEBUG_PRINTF(" -> Recreated border-style shorthand\n");
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ // Try to recreate border-color shorthand (if all 4 sides present)
660
+ {
661
+ VALUE top = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-top-color"));
662
+ VALUE right = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-right-color"));
663
+ VALUE bottom = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-bottom-color"));
664
+ VALUE left = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-left-color"));
665
+
666
+ if (!NIL_P(top) && !NIL_P(right) && !NIL_P(bottom) && !NIL_P(left)) {
667
+ VALUE top_imp = rb_hash_aref(top, ID2SYM(id_important));
668
+ VALUE right_imp = rb_hash_aref(right, ID2SYM(id_important));
669
+ VALUE bottom_imp = rb_hash_aref(bottom, ID2SYM(id_important));
670
+ VALUE left_imp = rb_hash_aref(left, ID2SYM(id_important));
671
+
672
+ if (RTEST(top_imp) == RTEST(right_imp) && RTEST(top_imp) == RTEST(bottom_imp) && RTEST(top_imp) == RTEST(left_imp)) {
673
+ VALUE props = rb_hash_new();
674
+ rb_hash_aset(props, STR_NEW_CSTR("border-top-color"), rb_hash_aref(top, ID2SYM(id_value)));
675
+ rb_hash_aset(props, STR_NEW_CSTR("border-right-color"), rb_hash_aref(right, ID2SYM(id_value)));
676
+ rb_hash_aset(props, STR_NEW_CSTR("border-bottom-color"), rb_hash_aref(bottom, ID2SYM(id_value)));
677
+ rb_hash_aset(props, STR_NEW_CSTR("border-left-color"), rb_hash_aref(left, ID2SYM(id_value)));
678
+
679
+ VALUE shorthand_value = cataract_create_border_color_shorthand(Qnil, props);
680
+ if (!NIL_P(shorthand_value)) {
681
+ VALUE shorthand_data = rb_hash_new();
682
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
683
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(top, ID2SYM(id_specificity)));
684
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), top_imp);
685
+ rb_hash_aset(properties_hash, USASCII_STR("border-color"), shorthand_data);
686
+
687
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-top-color"));
688
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-right-color"));
689
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-bottom-color"));
690
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-left-color"));
691
+ DEBUG_PRINTF(" -> Recreated border-color shorthand\n");
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // Try to recreate border-style shorthand (if all 4 sides present)
698
+ {
699
+ VALUE top = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-top-style"));
700
+ VALUE right = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-right-style"));
701
+ VALUE bottom = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-bottom-style"));
702
+ VALUE left = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-left-style"));
703
+
704
+ if (!NIL_P(top) && !NIL_P(right) && !NIL_P(bottom) && !NIL_P(left)) {
705
+ VALUE top_imp = rb_hash_aref(top, ID2SYM(id_important));
706
+ VALUE right_imp = rb_hash_aref(right, ID2SYM(id_important));
707
+ VALUE bottom_imp = rb_hash_aref(bottom, ID2SYM(id_important));
708
+ VALUE left_imp = rb_hash_aref(left, ID2SYM(id_important));
709
+
710
+ if (RTEST(top_imp) == RTEST(right_imp) && RTEST(top_imp) == RTEST(bottom_imp) && RTEST(top_imp) == RTEST(left_imp)) {
711
+ VALUE props = rb_hash_new();
712
+ rb_hash_aset(props, STR_NEW_CSTR("border-top-style"), rb_hash_aref(top, ID2SYM(id_value)));
713
+ rb_hash_aset(props, STR_NEW_CSTR("border-right-style"), rb_hash_aref(right, ID2SYM(id_value)));
714
+ rb_hash_aset(props, STR_NEW_CSTR("border-bottom-style"), rb_hash_aref(bottom, ID2SYM(id_value)));
715
+ rb_hash_aset(props, STR_NEW_CSTR("border-left-style"), rb_hash_aref(left, ID2SYM(id_value)));
716
+
717
+ VALUE shorthand_value = cataract_create_border_style_shorthand(Qnil, props);
718
+ if (!NIL_P(shorthand_value)) {
719
+ VALUE shorthand_data = rb_hash_new();
720
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
721
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(top, ID2SYM(id_specificity)));
722
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), top_imp);
723
+ rb_hash_aset(properties_hash, USASCII_STR("border-style"), shorthand_data);
724
+
725
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-top-style"));
726
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-right-style"));
727
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-bottom-style"));
728
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-left-style"));
729
+ DEBUG_PRINTF(" -> Recreated border-style shorthand\n");
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ // Try to recreate full border shorthand (if border-width, border-style, border-color present)
736
+ {
737
+ VALUE width = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-width"));
738
+ VALUE style = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-style"));
739
+ VALUE color = rb_hash_aref(properties_hash, STR_NEW_CSTR("border-color"));
740
+
741
+ // Need at least style (border shorthand requires style)
742
+ if (!NIL_P(style)) {
743
+ // Check all have same !important flag
744
+ VALUE style_imp = rb_hash_aref(style, ID2SYM(id_important));
745
+ int same_importance = 1;
746
+ if (!NIL_P(width)) same_importance = same_importance && (RTEST(style_imp) == RTEST(rb_hash_aref(width, ID2SYM(id_important))));
747
+ if (!NIL_P(color)) same_importance = same_importance && (RTEST(style_imp) == RTEST(rb_hash_aref(color, ID2SYM(id_important))));
748
+
749
+ if (same_importance) {
750
+ VALUE props = rb_hash_new();
751
+ if (!NIL_P(width)) rb_hash_aset(props, STR_NEW_CSTR("border-width"), rb_hash_aref(width, ID2SYM(id_value)));
752
+ rb_hash_aset(props, STR_NEW_CSTR("border-style"), rb_hash_aref(style, ID2SYM(id_value)));
753
+ if (!NIL_P(color)) rb_hash_aset(props, STR_NEW_CSTR("border-color"), rb_hash_aref(color, ID2SYM(id_value)));
754
+
755
+ VALUE shorthand_value = cataract_create_border_shorthand(Qnil, props);
756
+ if (!NIL_P(shorthand_value)) {
757
+ VALUE shorthand_data = rb_hash_new();
758
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
759
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(style, ID2SYM(id_specificity)));
760
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), style_imp);
761
+ rb_hash_aset(properties_hash, USASCII_STR("border"), shorthand_data);
762
+
763
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-width"));
764
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-style"));
765
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("border-color"));
766
+ DEBUG_PRINTF(" -> Recreated border shorthand\n");
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ // Try to recreate list-style shorthand
773
+ {
774
+ VALUE type = rb_hash_aref(properties_hash, STR_NEW_CSTR("list-style-type"));
775
+ VALUE position = rb_hash_aref(properties_hash, STR_NEW_CSTR("list-style-position"));
776
+ VALUE image = rb_hash_aref(properties_hash, STR_NEW_CSTR("list-style-image"));
777
+
778
+ // Need at least 2 properties to create shorthand
779
+ // Single property should stay as longhand (semantic difference)
780
+ int list_count = 0;
781
+ if (!NIL_P(type)) list_count++;
782
+ if (!NIL_P(position)) list_count++;
783
+ if (!NIL_P(image)) list_count++;
784
+
785
+ if (list_count >= 2) {
786
+ // Check all have same !important flag
787
+ VALUE first_imp = Qnil;
788
+ if (!NIL_P(type)) first_imp = rb_hash_aref(type, ID2SYM(id_important));
789
+ else if (!NIL_P(position)) first_imp = rb_hash_aref(position, ID2SYM(id_important));
790
+ else if (!NIL_P(image)) first_imp = rb_hash_aref(image, ID2SYM(id_important));
791
+
792
+ int same_importance = 1;
793
+ if (!NIL_P(type)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(type, ID2SYM(id_important))));
794
+ if (!NIL_P(position)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(position, ID2SYM(id_important))));
795
+ if (!NIL_P(image)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(image, ID2SYM(id_important))));
796
+
797
+ if (same_importance) {
798
+ VALUE props = rb_hash_new();
799
+ if (!NIL_P(type)) rb_hash_aset(props, STR_NEW_CSTR("list-style-type"), rb_hash_aref(type, ID2SYM(id_value)));
800
+ if (!NIL_P(position)) rb_hash_aset(props, STR_NEW_CSTR("list-style-position"), rb_hash_aref(position, ID2SYM(id_value)));
801
+ if (!NIL_P(image)) rb_hash_aset(props, STR_NEW_CSTR("list-style-image"), rb_hash_aref(image, ID2SYM(id_value)));
802
+
803
+ VALUE shorthand_value = cataract_create_list_style_shorthand(Qnil, props);
804
+ if (!NIL_P(shorthand_value)) {
805
+ VALUE shorthand_data = rb_hash_new();
806
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
807
+ VALUE first_prop = !NIL_P(type) ? type : (!NIL_P(position) ? position : image);
808
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(first_prop, ID2SYM(id_specificity)));
809
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), first_imp);
810
+ rb_hash_aset(properties_hash, USASCII_STR("list-style"), shorthand_data);
811
+
812
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("list-style-type"));
813
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("list-style-position"));
814
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("list-style-image"));
815
+ DEBUG_PRINTF(" -> Recreated list-style shorthand\n");
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ // Try to recreate font shorthand (requires at least font-size and font-family)
822
+ {
823
+ VALUE size = rb_hash_aref(properties_hash, STR_NEW_CSTR("font-size"));
824
+ VALUE family = rb_hash_aref(properties_hash, STR_NEW_CSTR("font-family"));
825
+
826
+ if (!NIL_P(size) && !NIL_P(family)) {
827
+ VALUE style = rb_hash_aref(properties_hash, STR_NEW_CSTR("font-style"));
828
+ VALUE variant = rb_hash_aref(properties_hash, STR_NEW_CSTR("font-variant"));
829
+ VALUE weight = rb_hash_aref(properties_hash, STR_NEW_CSTR("font-weight"));
830
+ VALUE line_height = rb_hash_aref(properties_hash, STR_NEW_CSTR("line-height"));
831
+
832
+ // Check all font properties have same !important flag
833
+ VALUE size_imp = rb_hash_aref(size, ID2SYM(id_important));
834
+ VALUE family_imp = rb_hash_aref(family, ID2SYM(id_important));
835
+
836
+ int same_importance = (RTEST(size_imp) == RTEST(family_imp));
837
+ if (!NIL_P(style)) same_importance = same_importance && (RTEST(size_imp) == RTEST(rb_hash_aref(style, ID2SYM(id_important))));
838
+ if (!NIL_P(variant)) same_importance = same_importance && (RTEST(size_imp) == RTEST(rb_hash_aref(variant, ID2SYM(id_important))));
839
+ if (!NIL_P(weight)) same_importance = same_importance && (RTEST(size_imp) == RTEST(rb_hash_aref(weight, ID2SYM(id_important))));
840
+ if (!NIL_P(line_height)) same_importance = same_importance && (RTEST(size_imp) == RTEST(rb_hash_aref(line_height, ID2SYM(id_important))));
841
+
842
+ if (same_importance) {
843
+ VALUE props = rb_hash_new();
844
+ rb_hash_aset(props, STR_NEW_CSTR("font-size"), rb_hash_aref(size, ID2SYM(id_value)));
845
+ rb_hash_aset(props, STR_NEW_CSTR("font-family"), rb_hash_aref(family, ID2SYM(id_value)));
846
+ if (!NIL_P(style)) rb_hash_aset(props, STR_NEW_CSTR("font-style"), rb_hash_aref(style, ID2SYM(id_value)));
847
+ if (!NIL_P(variant)) rb_hash_aset(props, STR_NEW_CSTR("font-variant"), rb_hash_aref(variant, ID2SYM(id_value)));
848
+ if (!NIL_P(weight)) rb_hash_aset(props, STR_NEW_CSTR("font-weight"), rb_hash_aref(weight, ID2SYM(id_value)));
849
+ if (!NIL_P(line_height)) rb_hash_aset(props, STR_NEW_CSTR("line-height"), rb_hash_aref(line_height, ID2SYM(id_value)));
850
+
851
+ VALUE shorthand_value = cataract_create_font_shorthand(Qnil, props);
852
+ if (!NIL_P(shorthand_value)) {
853
+ VALUE shorthand_data = rb_hash_new();
854
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
855
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(size, ID2SYM(id_specificity)));
856
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), size_imp);
857
+ rb_hash_aset(properties_hash, USASCII_STR("font"), shorthand_data);
858
+
859
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("font-size"));
860
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("font-family"));
861
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("font-style"));
862
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("font-variant"));
863
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("font-weight"));
864
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("line-height"));
865
+ DEBUG_PRINTF(" -> Recreated font shorthand\n");
866
+ }
867
+ }
868
+ }
869
+ }
870
+
871
+ // Try to recreate background shorthand (if 2+ properties present)
872
+ {
873
+ VALUE color = rb_hash_aref(properties_hash, STR_NEW_CSTR("background-color"));
874
+ VALUE image = rb_hash_aref(properties_hash, STR_NEW_CSTR("background-image"));
875
+ VALUE repeat = rb_hash_aref(properties_hash, STR_NEW_CSTR("background-repeat"));
876
+ VALUE position = rb_hash_aref(properties_hash, STR_NEW_CSTR("background-position"));
877
+ VALUE attachment = rb_hash_aref(properties_hash, STR_NEW_CSTR("background-attachment"));
878
+
879
+ int bg_count = 0;
880
+ if (!NIL_P(color)) bg_count++;
881
+ if (!NIL_P(image)) bg_count++;
882
+ if (!NIL_P(repeat)) bg_count++;
883
+ if (!NIL_P(position)) bg_count++;
884
+ if (!NIL_P(attachment)) bg_count++;
885
+
886
+ // Need at least 2 properties to create shorthand
887
+ if (bg_count >= 2) {
888
+ // Check all have same !important flag
889
+ VALUE first_imp = Qnil;
890
+ if (!NIL_P(color)) first_imp = rb_hash_aref(color, ID2SYM(id_important));
891
+ else if (!NIL_P(image)) first_imp = rb_hash_aref(image, ID2SYM(id_important));
892
+ else if (!NIL_P(repeat)) first_imp = rb_hash_aref(repeat, ID2SYM(id_important));
893
+ else if (!NIL_P(position)) first_imp = rb_hash_aref(position, ID2SYM(id_important));
894
+ else if (!NIL_P(attachment)) first_imp = rb_hash_aref(attachment, ID2SYM(id_important));
895
+
896
+ int same_importance = 1;
897
+ if (!NIL_P(color)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(color, ID2SYM(id_important))));
898
+ if (!NIL_P(image)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(image, ID2SYM(id_important))));
899
+ if (!NIL_P(repeat)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(repeat, ID2SYM(id_important))));
900
+ if (!NIL_P(position)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(position, ID2SYM(id_important))));
901
+ if (!NIL_P(attachment)) same_importance = same_importance && (RTEST(first_imp) == RTEST(rb_hash_aref(attachment, ID2SYM(id_important))));
902
+
903
+ if (same_importance) {
904
+ VALUE props = rb_hash_new();
905
+ if (!NIL_P(color)) rb_hash_aset(props, STR_NEW_CSTR("background-color"), rb_hash_aref(color, ID2SYM(id_value)));
906
+ if (!NIL_P(image)) rb_hash_aset(props, STR_NEW_CSTR("background-image"), rb_hash_aref(image, ID2SYM(id_value)));
907
+ if (!NIL_P(repeat)) rb_hash_aset(props, STR_NEW_CSTR("background-repeat"), rb_hash_aref(repeat, ID2SYM(id_value)));
908
+ if (!NIL_P(position)) rb_hash_aset(props, STR_NEW_CSTR("background-position"), rb_hash_aref(position, ID2SYM(id_value)));
909
+ if (!NIL_P(attachment)) rb_hash_aset(props, STR_NEW_CSTR("background-attachment"), rb_hash_aref(attachment, ID2SYM(id_value)));
910
+
911
+ VALUE shorthand_value = cataract_create_background_shorthand(Qnil, props);
912
+ if (!NIL_P(shorthand_value)) {
913
+ VALUE shorthand_data = rb_hash_new();
914
+ rb_hash_aset(shorthand_data, ID2SYM(id_value), shorthand_value);
915
+ VALUE first_prop = !NIL_P(color) ? color : (!NIL_P(image) ? image : (!NIL_P(repeat) ? repeat : (!NIL_P(position) ? position : attachment)));
916
+ rb_hash_aset(shorthand_data, ID2SYM(id_specificity), rb_hash_aref(first_prop, ID2SYM(id_specificity)));
917
+ rb_hash_aset(shorthand_data, ID2SYM(id_important), first_imp);
918
+ rb_hash_aset(properties_hash, USASCII_STR("background"), shorthand_data);
919
+
920
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("background-color"));
921
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("background-image"));
922
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("background-repeat"));
923
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("background-position"));
924
+ rb_hash_delete(properties_hash, STR_NEW_CSTR("background-attachment"));
925
+ DEBUG_PRINTF(" -> Recreated background shorthand\n");
926
+ }
927
+ }
928
+ }
929
+ }
930
+
931
+ // Build declarations array from properties_hash
932
+ VALUE merged_decls = rb_ary_new();
933
+ rb_hash_foreach(properties_hash, merge_build_result_callback, merged_decls);
934
+
935
+ DEBUG_PRINTF(" [merge_rules_for_selector] Result: %ld merged declarations\n",
936
+ RARRAY_LEN(merged_decls));
937
+
938
+ return merged_decls;
939
+ }
940
+
336
941
  // Merge CSS rules according to cascade rules
337
942
  // Input: Stylesheet object or CSS string
338
943
  // Output: Stylesheet with merged declarations
@@ -378,6 +983,78 @@ VALUE cataract_merge_new(VALUE self, VALUE input) {
378
983
  return empty_sheet;
379
984
  }
380
985
 
986
+ /*
987
+ * ============================================================================
988
+ * MERGE ALGORITHM - Rules and Implementation Notes
989
+ * ============================================================================
990
+ *
991
+ * CORE PRINCIPLE: Group rules by selector, merge declarations within each group
992
+ *
993
+ * Different selectors (.test vs #test) target different elements and must stay separate.
994
+ * Same selectors should merge into one rule to reduce output size.
995
+ *
996
+ * ALGORITHM STEPS:
997
+ * 1. Group rules by selector (.test, #test, etc.)
998
+ * 2. For each selector group:
999
+ * a. Expand shorthand properties (margin, background, font, etc.)
1000
+ * b. Apply CSS cascade rules to resolve conflicts
1001
+ * c. Recreate shorthand properties where beneficial
1002
+ * 3. Output one rule per unique selector
1003
+ *
1004
+ * CSS CASCADE RULES (in order of precedence):
1005
+ * 1. !important declarations always win over non-!important
1006
+ * 2. Higher specificity wins (#id > .class > element)
1007
+ * 3. Later source order wins (for same importance + specificity)
1008
+ *
1009
+ * SOURCE ORDER CALCULATION:
1010
+ * source_order = rule_id * 1000 + declaration_index
1011
+ * This ensures declarations within the same rule maintain relative order.
1012
+ *
1013
+ * SHORTHAND EXPANSION:
1014
+ * When merging, all shorthands must be expanded to longhands first.
1015
+ * Example: "background: blue" expands to:
1016
+ * - background-color: blue
1017
+ * - background-image: none
1018
+ * - background-repeat: repeat
1019
+ * - background-position: 0% 0%
1020
+ * - background-attachment: scroll
1021
+ *
1022
+ * This is REQUIRED because partial overrides must work correctly:
1023
+ * .test { background: blue; }
1024
+ * .test { background-image: url(x.png); }
1025
+ * Should result in: blue background with image (not image reset to none)
1026
+ *
1027
+ * SHORTHAND RECREATION:
1028
+ * After cascade resolution, recreate shorthands for smaller output:
1029
+ * - margin-top: 10px, margin-right: 10px, ... → margin: 10px
1030
+ * - background-color: blue, background-image: none, ... → background: blue
1031
+ *
1032
+ * Optimization: Omit default values ONLY when all properties are present
1033
+ * (indicating they came from shorthand expansion, not explicit longhands)
1034
+ *
1035
+ * If only some properties present (explicit longhands), include all values:
1036
+ * background-color: black, background-image: none → "black none"
1037
+ * Not: "black" (user explicitly set image to none)
1038
+ *
1039
+ * If all properties present (from expansion), omit defaults:
1040
+ * background-color: blue, background-image: none, repeat: repeat, ... → "blue"
1041
+ * (The "none", "repeat", etc. are just defaults from expansion)
1042
+ *
1043
+ * EDGE CASES:
1044
+ * - Empty rules (no declarations): Skip during merge
1045
+ * - Nested CSS: Parent rules with children are containers only, skip their declarations
1046
+ * - Mixed !important: Properties with different importance cannot merge into shorthand
1047
+ * - Single property: Don't create shorthand (e.g., background-color alone stays as-is)
1048
+ * Reason: "background: blue" resets all other background properties to defaults,
1049
+ * which is semantically different from just setting background-color.
1050
+ *
1051
+ * PERFORMANCE NOTES:
1052
+ * - Use cached static strings (VALUE) for property names (no allocation)
1053
+ * - Group by selector in single pass (O(n) hash building)
1054
+ * - Merge within groups (O(n*m) where m is avg declarations per rule)
1055
+ * ============================================================================
1056
+ */
1057
+
381
1058
  // For nested CSS: identify parent rules (rules that have children)
382
1059
  // These should be skipped during merge, even if they have declarations
383
1060
  // Use Ruby hash as a set: parent_id => true
@@ -400,69 +1077,61 @@ VALUE cataract_merge_new(VALUE self, VALUE input) {
400
1077
  }
401
1078
  }
402
1079
 
403
- // For nested CSS with different selectors from SAME parent: group rules by selector
404
- // Only split into multiple rules if ALL rules share the same parent_rule_id
1080
+ // ALWAYS build selector groups - this is the core of merge logic
1081
+ // Group rules by selector: different selectors stay separate
405
1082
  // selector => [rule indices]
406
- VALUE selector_groups = Qnil;
407
- VALUE common_parent = Qundef; // Qundef = not set yet
408
-
409
- if (has_nesting) {
410
- DEBUG_PRINTF("\n=== Building selector groups ===\n");
411
- selector_groups = rb_hash_new();
412
- for (long i = 0; i < num_rules; i++) {
413
- VALUE rule = RARRAY_AREF(rules_array, i);
414
- VALUE declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
415
- VALUE parent_rule_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
416
- VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1083
+ DEBUG_PRINTF("\n=== Building selector groups (has_nesting=%d) ===\n", has_nesting);
1084
+ VALUE selector_groups = rb_hash_new();
417
1085
 
418
- // Per W3C spec: parent and child are SEPARATE rules with different selectors
419
- // Both should be included in merge output
420
- // Don't skip parent rules - they have their own selector and declarations
1086
+ for (long i = 0; i < num_rules; i++) {
1087
+ VALUE rule = RARRAY_AREF(rules_array, i);
1088
+ VALUE declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1089
+ VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
421
1090
 
422
- // Skip empty rules (no declarations)
423
- if (RARRAY_LEN(declarations) == 0) {
424
- DEBUG_PRINTF(" Skipping rule %ld: selector='%s' (empty declarations)\n",
425
- i, RSTRING_PTR(selector));
426
- continue;
427
- }
1091
+ // Skip empty rules (no declarations)
1092
+ // This handles both empty containers and rules with no properties
1093
+ if (RARRAY_LEN(declarations) == 0) {
1094
+ DEBUG_PRINTF(" [Rule %ld] SKIP: selector='%s' (empty declarations)\n",
1095
+ i, RSTRING_PTR(selector));
1096
+ continue;
1097
+ }
428
1098
 
429
- DEBUG_PRINTF(" Processing rule %ld: selector='%s', parent_rule_id=%s\n",
430
- i, RSTRING_PTR(selector),
431
- NIL_P(parent_rule_id) ? "nil" : RSTRING_PTR(rb_inspect(parent_rule_id)));
1099
+ // Note: We do NOT skip parent rules that have children!
1100
+ // Per CSS spec, parent can have its own declarations AND nested rules.
1101
+ // Example: .parent { color: red; .child { color: blue; } }
1102
+ // Should output both .parent (color: red) and .parent .child (color: blue)
1103
+ // The nesting is already flattened during parsing, so they have different selectors.
432
1104
 
433
- // Track if all rules share the same parent
434
- if (common_parent == Qundef) {
435
- common_parent = parent_rule_id;
436
- DEBUG_PRINTF(" Setting common_parent=%s\n",
437
- NIL_P(common_parent) ? "nil" : RSTRING_PTR(rb_inspect(common_parent)));
438
- }
1105
+ DEBUG_PRINTF(" [Rule %ld] ADD: selector='%s', %ld declarations\n",
1106
+ i, RSTRING_PTR(selector), RARRAY_LEN(declarations));
439
1107
 
440
- VALUE group = rb_hash_aref(selector_groups, selector);
441
- if (NIL_P(group)) {
442
- group = rb_ary_new();
443
- rb_hash_aset(selector_groups, selector, group);
444
- DEBUG_PRINTF(" Created new group for selector='%s'\n", RSTRING_PTR(selector));
445
- }
446
- rb_ary_push(group, LONG2FIX(i));
1108
+ VALUE group = rb_hash_aref(selector_groups, selector);
1109
+ if (NIL_P(group)) {
1110
+ group = rb_ary_new();
1111
+ rb_hash_aset(selector_groups, selector, group);
1112
+ DEBUG_PRINTF(" -> Created new selector group for '%s'\n", RSTRING_PTR(selector));
447
1113
  }
448
- DEBUG_PRINTF(" Total selector groups: %ld\n", RHASH_SIZE(selector_groups));
1114
+ rb_ary_push(group, LONG2FIX(i));
449
1115
  }
1116
+ DEBUG_PRINTF("=== Total selector groups: %ld ===\n\n", RHASH_SIZE(selector_groups));
450
1117
 
451
- // If nested CSS with multiple distinct selectors, return separate rules
452
- // Per W3C spec: each unique selector (parent or child) is a separate rule
453
- // Example: .parent { color: red; .child { color: blue; } } .other { color: green; }
454
- // Should return 3 rules: .parent, .parent .child, .other
455
- DEBUG_PRINTF("\n=== Decision point ===\n");
456
- DEBUG_PRINTF(" has_nesting=%d\n", has_nesting);
457
- DEBUG_PRINTF(" selector_groups is nil? %d\n", NIL_P(selector_groups));
458
- if (!NIL_P(selector_groups)) {
459
- DEBUG_PRINTF(" selector_groups size=%ld\n", RHASH_SIZE(selector_groups));
1118
+ // ALWAYS group by selector and keep them separate
1119
+ // Different selectors target different elements and must remain distinct
1120
+ // Example: .test { color: red; } #test { color: blue; }
1121
+ // Should return 2 rules (not merged into one)
1122
+ DEBUG_PRINTF("=== DECISION POINT ===\n");
1123
+ DEBUG_PRINTF(" selector_groups size: %ld\n", RHASH_SIZE(selector_groups));
1124
+
1125
+ if (RHASH_SIZE(selector_groups) == 0) {
1126
+ DEBUG_PRINTF(" -> No rules to merge (all were empty or skipped)\n");
1127
+ // Return empty stylesheet
1128
+ VALUE empty_sheet = rb_class_new_instance(0, NULL, cStylesheet);
1129
+ return empty_sheet;
460
1130
  }
461
- DEBUG_PRINTF(" Condition: has_nesting && !NIL_P(selector_groups) && RHASH_SIZE(selector_groups) > 1 = %d\n",
462
- has_nesting && !NIL_P(selector_groups) && RHASH_SIZE(selector_groups) > 1);
463
1131
 
464
- if (has_nesting && !NIL_P(selector_groups) && RHASH_SIZE(selector_groups) > 1) {
465
- DEBUG_PRINTF(" -> Taking MULTI-SELECTOR path (separate rules)\n");
1132
+ if (RHASH_SIZE(selector_groups) > 0) {
1133
+ DEBUG_PRINTF(" -> Taking SELECTOR-GROUPED path (%ld unique selectors)\n",
1134
+ RHASH_SIZE(selector_groups));
466
1135
  VALUE merged_sheet = rb_class_new_instance(0, NULL, cStylesheet);
467
1136
  VALUE merged_rules = rb_ary_new();
468
1137
  int rule_id_counter = 0;
@@ -470,22 +1139,23 @@ VALUE cataract_merge_new(VALUE self, VALUE input) {
470
1139
  // Iterate through each selector group
471
1140
  VALUE selectors = rb_funcall(selector_groups, rb_intern("keys"), 0);
472
1141
  long num_selectors = RARRAY_LEN(selectors);
1142
+ DEBUG_PRINTF("\n=== Processing %ld selector groups ===\n", num_selectors);
473
1143
 
474
1144
  for (long s = 0; s < num_selectors; s++) {
475
1145
  VALUE selector = rb_ary_entry(selectors, s);
476
1146
  VALUE group_indices = rb_hash_aref(selector_groups, selector);
477
1147
 
478
- // For now, just take first rule from each group (no merging within group)
479
- // TODO: Merge declarations within same-selector group
480
- long first_idx = FIX2LONG(rb_ary_entry(group_indices, 0));
481
- VALUE orig_rule = RARRAY_AREF(rules_array, first_idx);
482
- VALUE orig_decls = rb_struct_aref(orig_rule, INT2FIX(RULE_DECLARATIONS));
1148
+ DEBUG_PRINTF("\n[Selector %ld/%ld] '%s' - %ld rules in group\n",
1149
+ s + 1, num_selectors, RSTRING_PTR(selector), RARRAY_LEN(group_indices));
483
1150
 
484
- // Create new rule with this selector and declarations
1151
+ // Merge all rules in this selector group
1152
+ VALUE merged_decls = merge_rules_for_selector(rules_array, group_indices, selector);
1153
+
1154
+ // Create new rule with this selector and merged declarations
485
1155
  VALUE new_rule = rb_struct_new(cRule,
486
1156
  INT2FIX(rule_id_counter++),
487
1157
  selector,
488
- orig_decls,
1158
+ merged_decls,
489
1159
  Qnil, // specificity
490
1160
  Qnil, // parent_rule_id
491
1161
  Qnil // nesting_style
@@ -493,6 +1163,8 @@ VALUE cataract_merge_new(VALUE self, VALUE input) {
493
1163
  rb_ary_push(merged_rules, new_rule);
494
1164
  }
495
1165
 
1166
+ DEBUG_PRINTF("\n=== Created %d output rules ===\n", rule_id_counter);
1167
+
496
1168
  rb_ivar_set(merged_sheet, id_ivar_rules, merged_rules);
497
1169
 
498
1170
  // Set @media_index with :all pointing to all rule IDs