cataract 0.2.0 → 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.
@@ -32,10 +32,21 @@ VALUE eSizeError;
32
32
  * This matches the old parse_css API
33
33
  *
34
34
  * @param css_string [String] CSS to parse
35
+ * @param parser_options [Hash] Parser options (optional, defaults to {})
35
36
  * @return [Hash] { rules: [...], media_index: {...}, charset: "..." }
36
37
  */
37
- VALUE parse_css_new(VALUE self, VALUE css_string) {
38
- return parse_css_new_impl(css_string, 0);
38
+ VALUE parse_css_new(int argc, VALUE *argv, VALUE self) {
39
+ VALUE css_string, parser_options;
40
+
41
+ // Parse arguments: required css_string, optional parser_options hash
42
+ rb_scan_args(argc, argv, "11", &css_string, &parser_options);
43
+
44
+ // Default to empty hash if not provided
45
+ if (NIL_P(parser_options)) {
46
+ parser_options = rb_hash_new();
47
+ }
48
+
49
+ return parse_css_new_impl(css_string, parser_options, 0);
39
50
  }
40
51
 
41
52
  /*
@@ -292,6 +303,17 @@ struct build_rule_map_ctx {
292
303
  VALUE rule_to_media;
293
304
  };
294
305
 
306
+ // Formatting options for stylesheet serialization
307
+ // Avoids mode flags and if/else branches - all behavior controlled by struct values
308
+ struct format_opts {
309
+ const char *opening_brace; // " { " (compact) vs " {\n" (formatted)
310
+ const char *closing_brace; // " }\n" (compact) vs "}\n" (formatted)
311
+ const char *media_indent; // "" (compact) vs " " (formatted)
312
+ const char *decl_indent_base; // NULL (compact) vs " " (formatted base rules)
313
+ const char *decl_indent_media; // NULL (compact) vs " " (formatted media rules)
314
+ int add_blank_lines; // 0 (compact) vs 1 (formatted)
315
+ };
316
+
295
317
  // Callback to build reverse map from rule_id to media_sym
296
318
  static int build_rule_map_callback(VALUE media_sym, VALUE rule_ids, VALUE arg) {
297
319
  struct build_rule_map_ctx *ctx = (struct build_rule_map_ctx *)arg;
@@ -318,27 +340,28 @@ static int build_rule_map_callback(VALUE media_sym, VALUE rule_ids, VALUE arg) {
318
340
  return ST_CONTINUE;
319
341
  }
320
342
 
321
- // Original stylesheet serialization (no nesting support)
322
- static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALUE charset) {
323
- Check_Type(rules_array, T_ARRAY);
324
- Check_Type(media_index, T_HASH);
325
-
326
- VALUE result = rb_str_new_cstr("");
327
-
328
- // Add charset if present
329
- if (!NIL_P(charset)) {
330
- rb_str_cat2(result, "@charset \"");
331
- rb_str_append(result, charset);
332
- rb_str_cat2(result, "\";\n");
333
- }
334
-
343
+ // Private shared implementation for stylesheet serialization with optional selector list grouping
344
+ // All formatting behavior controlled by format_opts struct to avoid mode flags and if/else branches
345
+ static VALUE serialize_stylesheet_with_grouping(
346
+ VALUE rules_array,
347
+ VALUE media_index,
348
+ VALUE result,
349
+ VALUE selector_lists,
350
+ const struct format_opts *opts
351
+ ) {
335
352
  long total_rules = RARRAY_LEN(rules_array);
336
353
 
354
+ // Check if selector list grouping is enabled (non-empty hash)
355
+ int grouping_enabled = (!NIL_P(selector_lists) && TYPE(selector_lists) == T_HASH && RHASH_SIZE(selector_lists) > 0);
356
+
337
357
  // Build a map from rule_id to media query symbol using rb_hash_foreach
338
358
  VALUE rule_to_media = rb_hash_new();
339
359
  struct build_rule_map_ctx map_ctx = { rule_to_media };
340
360
  rb_hash_foreach(media_index, build_rule_map_callback, (VALUE)&map_ctx);
341
361
 
362
+ // Track processed rules to avoid duplicates when grouping
363
+ VALUE processed_rule_ids = rb_hash_new();
364
+
342
365
  // Iterate through rules in insertion order, grouping consecutive media queries
343
366
  VALUE current_media = Qnil;
344
367
  int in_media_block = 0;
@@ -346,7 +369,14 @@ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALU
346
369
  for (long i = 0; i < total_rules; i++) {
347
370
  VALUE rule = rb_ary_entry(rules_array, i);
348
371
  VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
372
+
373
+ // Skip if already processed (when grouped)
374
+ if (RTEST(rb_hash_aref(processed_rule_ids, rule_id))) {
375
+ continue;
376
+ }
377
+
349
378
  VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
379
+ int is_first_rule = (i == 0);
350
380
 
351
381
  if (NIL_P(rule_media)) {
352
382
  // Not in any media query - close any open media block first
@@ -356,8 +386,100 @@ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALU
356
386
  current_media = Qnil;
357
387
  }
358
388
 
359
- // Output rule directly
360
- serialize_rule(result, rule);
389
+ // Add blank line prefix for non-first rules (formatted only)
390
+ if (opts->add_blank_lines && !is_first_rule) {
391
+ rb_str_cat2(result, "\n");
392
+ }
393
+
394
+ // Try to group with other rules from same selector list
395
+ // Check if this is a Rule (not AtRule) before accessing selector_list_id
396
+ if (grouping_enabled && rb_obj_is_kind_of(rule, cRule)) {
397
+ VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
398
+ if (!NIL_P(selector_list_id)) {
399
+ // Get list of rule IDs in this selector list
400
+ VALUE rule_ids_in_list = rb_hash_aref(selector_lists, selector_list_id);
401
+
402
+ if (NIL_P(rule_ids_in_list) || RARRAY_LEN(rule_ids_in_list) <= 1) {
403
+ // Just this rule, serialize normally
404
+ if (opts->decl_indent_base) {
405
+ serialize_rule_formatted(result, rule, "", 1);
406
+ } else {
407
+ serialize_rule(result, rule);
408
+ }
409
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
410
+ } else {
411
+ // Find all rules with matching declarations and same media context
412
+ VALUE matching_selectors = rb_ary_new();
413
+ VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
414
+
415
+ long list_len = RARRAY_LEN(rule_ids_in_list);
416
+ for (long j = 0; j < list_len; j++) {
417
+ VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
418
+
419
+ // Skip if already processed
420
+ if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) {
421
+ continue;
422
+ }
423
+
424
+ // Find the rule by ID
425
+ VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
426
+ if (NIL_P(other_rule)) continue;
427
+
428
+ // Check same media context (both should be nil for base rules)
429
+ VALUE other_rule_media = rb_hash_aref(rule_to_media, other_rule_id);
430
+ if (!rb_equal(rule_media, other_rule_media)) {
431
+ continue;
432
+ }
433
+
434
+ // Check if declarations match
435
+ VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
436
+ if (rb_equal(rule_declarations, other_declarations)) {
437
+ VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
438
+ rb_ary_push(matching_selectors, other_selector);
439
+ rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
440
+ }
441
+ }
442
+
443
+ // Serialize grouped or single rule
444
+ if (RARRAY_LEN(matching_selectors) > 1) {
445
+ // Group selectors with comma-space separator
446
+ VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
447
+ rb_str_append(result, selector_str);
448
+ rb_str_cat2(result, opts->opening_brace);
449
+ if (opts->decl_indent_base) {
450
+ serialize_declarations_formatted(result, rule_declarations, opts->decl_indent_base);
451
+ } else {
452
+ serialize_declarations(result, rule_declarations);
453
+ }
454
+ rb_str_cat2(result, opts->closing_brace);
455
+ RB_GC_GUARD(selector_str);
456
+ } else {
457
+ // Just one rule, serialize normally
458
+ if (opts->decl_indent_base) {
459
+ serialize_rule_formatted(result, rule, "", 1);
460
+ } else {
461
+ serialize_rule(result, rule);
462
+ }
463
+ }
464
+ }
465
+ } else {
466
+ // No selector_list_id, serialize normally
467
+ if (opts->decl_indent_base) {
468
+ serialize_rule_formatted(result, rule, "", 1);
469
+ } else {
470
+ serialize_rule(result, rule);
471
+ }
472
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
473
+ }
474
+ } else {
475
+ // Grouping disabled, serialize normally
476
+ if (opts->decl_indent_base) {
477
+ serialize_rule_formatted(result, rule, "", 1);
478
+ } else {
479
+ serialize_rule(result, rule);
480
+ }
481
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
482
+ }
361
483
  } else {
362
484
  // This rule is in a media query
363
485
  // Check if media query changed from previous rule
@@ -367,6 +489,11 @@ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALU
367
489
  rb_str_cat2(result, "}\n");
368
490
  }
369
491
 
492
+ // Add blank line prefix for non-first rules (formatted only)
493
+ if (opts->add_blank_lines && !is_first_rule) {
494
+ rb_str_cat2(result, "\n");
495
+ }
496
+
370
497
  // Open new media block
371
498
  current_media = rule_media;
372
499
  rb_str_cat2(result, "@media ");
@@ -375,8 +502,80 @@ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALU
375
502
  in_media_block = 1;
376
503
  }
377
504
 
378
- // Serialize rule inside media block
379
- serialize_rule(result, rule);
505
+ // Serialize rule inside media block (with grouping if enabled)
506
+ // Check if this is a Rule (not AtRule) before accessing selector_list_id
507
+ if (grouping_enabled && rb_obj_is_kind_of(rule, cRule)) {
508
+ VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
509
+ if (!NIL_P(selector_list_id)) {
510
+ VALUE rule_ids_in_list = rb_hash_aref(selector_lists, selector_list_id);
511
+
512
+ if (NIL_P(rule_ids_in_list) || RARRAY_LEN(rule_ids_in_list) <= 1) {
513
+ if (opts->decl_indent_media) {
514
+ serialize_rule_formatted(result, rule, opts->media_indent, 1);
515
+ } else {
516
+ serialize_rule(result, rule);
517
+ }
518
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
519
+ } else {
520
+ VALUE matching_selectors = rb_ary_new();
521
+ VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
522
+
523
+ long list_len = RARRAY_LEN(rule_ids_in_list);
524
+ for (long j = 0; j < list_len; j++) {
525
+ VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
526
+ if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) continue;
527
+
528
+ VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
529
+ if (NIL_P(other_rule)) continue;
530
+
531
+ VALUE other_rule_media = rb_hash_aref(rule_to_media, other_rule_id);
532
+ if (!rb_equal(rule_media, other_rule_media)) continue;
533
+
534
+ VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
535
+ if (rb_equal(rule_declarations, other_declarations)) {
536
+ VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
537
+ rb_ary_push(matching_selectors, other_selector);
538
+ rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
539
+ }
540
+ }
541
+
542
+ if (RARRAY_LEN(matching_selectors) > 1) {
543
+ VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
544
+ rb_str_cat2(result, opts->media_indent);
545
+ rb_str_append(result, selector_str);
546
+ rb_str_cat2(result, opts->opening_brace);
547
+ if (opts->decl_indent_media) {
548
+ serialize_declarations_formatted(result, rule_declarations, opts->decl_indent_media);
549
+ } else {
550
+ serialize_declarations(result, rule_declarations);
551
+ }
552
+ rb_str_cat2(result, opts->media_indent);
553
+ rb_str_cat2(result, opts->closing_brace);
554
+ RB_GC_GUARD(selector_str);
555
+ } else {
556
+ if (opts->decl_indent_media) {
557
+ serialize_rule_formatted(result, rule, opts->media_indent, 1);
558
+ } else {
559
+ serialize_rule(result, rule);
560
+ }
561
+ }
562
+ }
563
+ } else {
564
+ if (opts->decl_indent_media) {
565
+ serialize_rule_formatted(result, rule, opts->media_indent, 1);
566
+ } else {
567
+ serialize_rule(result, rule);
568
+ }
569
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
570
+ }
571
+ } else {
572
+ if (opts->decl_indent_media) {
573
+ serialize_rule_formatted(result, rule, opts->media_indent, 1);
574
+ } else {
575
+ serialize_rule(result, rule);
576
+ }
577
+ rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
578
+ }
380
579
  }
381
580
  }
382
581
 
@@ -385,9 +584,38 @@ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALU
385
584
  rb_str_cat2(result, "}\n");
386
585
  }
387
586
 
587
+ RB_GC_GUARD(rule_to_media);
588
+ RB_GC_GUARD(processed_rule_ids);
388
589
  return result;
389
590
  }
390
591
 
592
+ // Original stylesheet serialization (no nesting support) - compact format
593
+ static VALUE stylesheet_to_s_original(VALUE rules_array, VALUE media_index, VALUE charset, VALUE selector_lists) {
594
+ Check_Type(rules_array, T_ARRAY);
595
+ Check_Type(media_index, T_HASH);
596
+
597
+ VALUE result = rb_str_new_cstr("");
598
+
599
+ // Add charset if present
600
+ if (!NIL_P(charset)) {
601
+ rb_str_cat2(result, "@charset \"");
602
+ rb_str_append(result, charset);
603
+ rb_str_cat2(result, "\";\n");
604
+ }
605
+
606
+ // Compact formatting options
607
+ struct format_opts opts = {
608
+ .opening_brace = " { ",
609
+ .closing_brace = " }\n",
610
+ .media_indent = "",
611
+ .decl_indent_base = NULL,
612
+ .decl_indent_media = NULL,
613
+ .add_blank_lines = 0
614
+ };
615
+
616
+ return serialize_stylesheet_with_grouping(rules_array, media_index, result, selector_lists, &opts);
617
+ }
618
+
391
619
  // Forward declarations
392
620
  static void serialize_children_only(VALUE result, VALUE rules_array, long rule_idx,
393
621
  VALUE rule_to_media, VALUE parent_to_children, VALUE parent_selector,
@@ -549,18 +777,34 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
549
777
 
550
778
  if (formatted) {
551
779
  // Formatted output with indentation
780
+ DEBUG_PRINTF("[SERIALIZE_RULE] Formatted mode, indent_level=%d, selector=%s\n", indent_level, RSTRING_PTR(selector));
552
781
  rb_str_append(result, selector);
553
782
  rb_str_cat2(result, " {\n");
554
783
 
784
+ // Build indent strings based on indent_level
785
+ // Declarations are inside the rule, so add 1 level (2 spaces per level)
786
+ // Closing brace matches the opening selector level
787
+ char decl_indent[MAX_INDENT_BUFFER];
788
+ char closing_indent[MAX_INDENT_BUFFER];
789
+ int decl_spaces = (indent_level + 1) * 2;
790
+ int closing_spaces = indent_level * 2;
791
+ memset(decl_indent, ' ', decl_spaces);
792
+ decl_indent[decl_spaces] = '\0';
793
+ memset(closing_indent, ' ', closing_spaces);
794
+ closing_indent[closing_spaces] = '\0';
795
+
555
796
  // Serialize own declarations with indentation (each on its own line)
556
797
  if (!NIL_P(declarations) && RARRAY_LEN(declarations) > 0) {
557
- serialize_declarations_formatted(result, declarations, " ");
798
+ DEBUG_PRINTF("[SERIALIZE_RULE] Serializing %ld declarations with indent='%s' (%d spaces)\n",
799
+ RARRAY_LEN(declarations), decl_indent, decl_spaces);
800
+ serialize_declarations_formatted(result, declarations, decl_indent);
558
801
  }
559
802
 
560
803
  // Serialize nested children
561
804
  serialize_children_only(result, rules_array, rule_idx, rule_to_media, parent_to_children,
562
805
  selector, declarations, formatted, indent_level + 1);
563
806
 
807
+ rb_str_cat2(result, closing_indent);
564
808
  rb_str_cat2(result, "}\n");
565
809
  } else {
566
810
  // Compact output
@@ -576,16 +820,21 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
576
820
 
577
821
  rb_str_cat2(result, " }\n");
578
822
  }
823
+
824
+ // Prevent compiler from optimizing away 'rule' before we're done with selector/declarations
825
+ RB_GC_GUARD(rule);
579
826
  }
580
827
 
581
828
  // New stylesheet serialization entry point - checks for nesting and delegates
582
- static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_index, VALUE charset, VALUE has_nesting) {
829
+ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_index, VALUE charset, VALUE has_nesting, VALUE selector_lists) {
583
830
  Check_Type(rules_array, T_ARRAY);
584
831
  Check_Type(media_index, T_HASH);
832
+ // TODO: Phase 2 - use selector_lists for grouping
833
+ (void)selector_lists; // Suppress unused parameter warning
585
834
 
586
835
  // Fast path: if no nesting, use original implementation (zero overhead)
587
836
  if (!RTEST(has_nesting)) {
588
- return stylesheet_to_s_original(rules_array, media_index, charset);
837
+ return stylesheet_to_s_original(rules_array, media_index, charset, selector_lists);
589
838
  }
590
839
 
591
840
  // SLOW PATH: Has nesting - use lookahead approach
@@ -626,6 +875,10 @@ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_inde
626
875
 
627
876
  DEBUG_PRINTF("[MAP] parent_to_children map: %s\n", RSTRING_PTR(rb_inspect(parent_to_children)));
628
877
 
878
+ // Track media block state for proper opening/closing
879
+ VALUE current_media = Qnil;
880
+ int in_media_block = 0;
881
+
629
882
  // Serialize only top-level rules (parent_rule_id == nil)
630
883
  // Children are serialized recursively
631
884
  DEBUG_PRINTF("[SERIALIZE] Starting serialization, total_rules=%ld\n", total_rules);
@@ -643,78 +896,25 @@ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_inde
643
896
  continue;
644
897
  }
645
898
 
646
- // Check if this is an AtRule
647
- if (rb_obj_is_kind_of(rule, cAtRule)) {
648
- serialize_at_rule(result, rule);
649
- continue;
650
- }
651
-
652
- // Serialize rule with nested children
653
- serialize_rule_with_children(
654
- result, rules_array, i, rule_to_media, parent_to_children,
655
- 0, // formatted (compact)
656
- 0 // indent_level (top-level)
657
- );
658
- }
659
-
660
- return result;
661
- }
662
-
663
- // Original formatted serialization (no nesting support)
664
- static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_index, VALUE charset) {
665
- long total_rules = RARRAY_LEN(rules_array);
666
- VALUE result = rb_str_new_cstr("");
667
-
668
- // Add charset if present
669
- if (!NIL_P(charset)) {
670
- rb_str_cat2(result, "@charset \"");
671
- rb_str_append(result, charset);
672
- rb_str_cat2(result, "\";\n");
673
- }
674
-
675
- // Build a map from rule_id to media query symbol
676
- VALUE rule_to_media = rb_hash_new();
677
- struct build_rule_map_ctx map_ctx = { rule_to_media };
678
- rb_hash_foreach(media_index, build_rule_map_callback, (VALUE)&map_ctx);
679
-
680
- // Iterate through rules, grouping consecutive media queries
681
- VALUE current_media = Qnil;
682
- int in_media_block = 0;
683
-
684
- for (long i = 0; i < total_rules; i++) {
685
- VALUE rule = rb_ary_entry(rules_array, i);
899
+ // Get media for this rule
686
900
  VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
687
901
  VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
688
- int is_first_rule = (i == 0);
689
902
 
903
+ // Handle media block transitions
690
904
  if (NIL_P(rule_media)) {
691
- // Not in any media query - close any open media block first
905
+ // Not in media - close any open media block
692
906
  if (in_media_block) {
693
907
  rb_str_cat2(result, "}\n");
694
908
  in_media_block = 0;
695
909
  current_media = Qnil;
696
910
  }
697
-
698
- // Add blank line prefix for non-first rules
699
- if (!is_first_rule) {
700
- rb_str_cat2(result, "\n");
701
- }
702
-
703
- // Output rule with no indentation (always single newline suffix)
704
- serialize_rule_formatted(result, rule, "", 1);
705
911
  } else {
706
- // This rule is in a media query
912
+ // In media - check if we need to open/change block
707
913
  if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
708
914
  // Close previous media block if open
709
915
  if (in_media_block) {
710
916
  rb_str_cat2(result, "}\n");
711
917
  }
712
-
713
- // Add blank line prefix for non-first rules
714
- if (!is_first_rule) {
715
- rb_str_cat2(result, "\n");
716
- }
717
-
718
918
  // Open new media block
719
919
  current_media = rule_media;
720
920
  rb_str_cat2(result, "@media ");
@@ -722,11 +922,20 @@ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_i
722
922
  rb_str_cat2(result, " {\n");
723
923
  in_media_block = 1;
724
924
  }
925
+ }
725
926
 
726
- // Serialize rule inside media block with 2-space indentation
727
- // Rules inside media blocks always get single newline (is_last=1)
728
- serialize_rule_formatted(result, rule, " ", 1);
927
+ // Check if this is an AtRule
928
+ if (rb_obj_is_kind_of(rule, cAtRule)) {
929
+ serialize_at_rule(result, rule);
930
+ continue;
729
931
  }
932
+
933
+ // Serialize rule with nested children
934
+ serialize_rule_with_children(
935
+ result, rules_array, i, rule_to_media, parent_to_children,
936
+ 0, // formatted (compact)
937
+ 0 // indent_level (top-level)
938
+ );
730
939
  }
731
940
 
732
941
  // Close final media block if still open
@@ -734,17 +943,43 @@ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_i
734
943
  rb_str_cat2(result, "}\n");
735
944
  }
736
945
 
946
+ RB_GC_GUARD(rule_to_media);
947
+ RB_GC_GUARD(parent_to_children);
737
948
  return result;
738
949
  }
739
950
 
951
+ // Original formatted serialization (no nesting support)
952
+ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_index, VALUE charset, VALUE selector_lists) {
953
+ VALUE result = rb_str_new_cstr("");
954
+
955
+ // Add charset if present
956
+ if (!NIL_P(charset)) {
957
+ rb_str_cat2(result, "@charset \"");
958
+ rb_str_append(result, charset);
959
+ rb_str_cat2(result, "\";\n");
960
+ }
961
+
962
+ // Formatted output options
963
+ struct format_opts opts = {
964
+ .opening_brace = " {\n",
965
+ .closing_brace = "}\n",
966
+ .media_indent = " ",
967
+ .decl_indent_base = " ",
968
+ .decl_indent_media = " ",
969
+ .add_blank_lines = 1
970
+ };
971
+
972
+ return serialize_stylesheet_with_grouping(rules_array, media_index, result, selector_lists, &opts);
973
+ }
974
+
740
975
  // Formatted version with indentation and newlines (with nesting support)
741
- static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE media_index, VALUE charset, VALUE has_nesting) {
976
+ static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE media_index, VALUE charset, VALUE has_nesting, VALUE selector_lists) {
742
977
  Check_Type(rules_array, T_ARRAY);
743
978
  Check_Type(media_index, T_HASH);
744
979
 
745
980
  // Fast path: if no nesting, use original implementation (zero overhead)
746
981
  if (!RTEST(has_nesting)) {
747
- return stylesheet_to_formatted_s_original(rules_array, media_index, charset);
982
+ return stylesheet_to_formatted_s_original(rules_array, media_index, charset, selector_lists);
748
983
  }
749
984
 
750
985
  // SLOW PATH: Has nesting - use parameterized serialization with formatted=1
@@ -779,6 +1014,10 @@ static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE
779
1014
  }
780
1015
  }
781
1016
 
1017
+ // Track media block state for proper opening/closing
1018
+ VALUE current_media = Qnil;
1019
+ int in_media_block = 0;
1020
+
782
1021
  // Serialize only top-level rules (parent_rule_id == nil)
783
1022
  for (long i = 0; i < total_rules; i++) {
784
1023
  VALUE rule = rb_ary_entry(rules_array, i);
@@ -789,20 +1028,68 @@ static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE
789
1028
  continue;
790
1029
  }
791
1030
 
1031
+ // Get media for this rule
1032
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
1033
+ VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
1034
+
1035
+ // Handle media block transitions
1036
+ if (NIL_P(rule_media)) {
1037
+ // Not in media - close any open media block
1038
+ if (in_media_block) {
1039
+ rb_str_cat2(result, "}\n");
1040
+ in_media_block = 0;
1041
+ current_media = Qnil;
1042
+
1043
+ // Add blank line after closing media block
1044
+ rb_str_cat2(result, "\n");
1045
+ }
1046
+ } else {
1047
+ // In media - check if we need to open/change block
1048
+ if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
1049
+ // Close previous media block if open
1050
+ if (in_media_block) {
1051
+ rb_str_cat2(result, "}\n");
1052
+ } else if (RSTRING_LEN(result) > 0) {
1053
+ // Add blank line before new media block (except at start)
1054
+ rb_str_cat2(result, "\n");
1055
+ }
1056
+ // Open new media block
1057
+ current_media = rule_media;
1058
+ rb_str_cat2(result, "@media ");
1059
+ rb_str_append(result, rb_sym2str(rule_media));
1060
+ rb_str_cat2(result, " {\n");
1061
+ in_media_block = 1;
1062
+ }
1063
+ }
1064
+
792
1065
  // Check if this is an AtRule
793
1066
  if (rb_obj_is_kind_of(rule, cAtRule)) {
794
1067
  serialize_at_rule(result, rule);
795
1068
  continue;
796
1069
  }
797
1070
 
1071
+ // Add indent if inside media block
1072
+ if (in_media_block) {
1073
+ DEBUG_PRINTF("[FORMATTED] Adding base indent for media block\n");
1074
+ rb_str_cat2(result, " ");
1075
+ }
1076
+
798
1077
  // Serialize rule with nested children
1078
+ DEBUG_PRINTF("[FORMATTED] Calling serialize_rule_with_children, in_media_block=%d\n", in_media_block);
799
1079
  serialize_rule_with_children(
800
1080
  result, rules_array, i, rule_to_media, parent_to_children,
801
1081
  1, // formatted (with indentation)
802
- 0 // indent_level (top-level)
1082
+ in_media_block ? 1 : 0 // indent_level (1 if inside media block, 0 otherwise)
803
1083
  );
804
1084
  }
805
1085
 
1086
+ // Close final media block if still open
1087
+ if (in_media_block) {
1088
+ rb_str_cat2(result, "}\n");
1089
+ }
1090
+
1091
+ RB_GC_GUARD(rule_to_media);
1092
+ RB_GC_GUARD(parent_to_children);
806
1093
  return result;
807
1094
  }
808
1095
 
@@ -1059,9 +1346,9 @@ void Init_native_extension(void) {
1059
1346
  cStylesheet = rb_define_class_under(mCataract, "Stylesheet", rb_cObject);
1060
1347
 
1061
1348
  // Define module functions
1062
- rb_define_module_function(mCataract, "_parse_css", parse_css_new, 1);
1063
- rb_define_module_function(mCataract, "_stylesheet_to_s", stylesheet_to_s_new, 4);
1064
- rb_define_module_function(mCataract, "_stylesheet_to_formatted_s", stylesheet_to_formatted_s_new, 4);
1349
+ rb_define_module_function(mCataract, "_parse_css", parse_css_new, -1);
1350
+ rb_define_module_function(mCataract, "_stylesheet_to_s", stylesheet_to_s_new, 5);
1351
+ rb_define_module_function(mCataract, "_stylesheet_to_formatted_s", stylesheet_to_formatted_s_new, 5);
1065
1352
  rb_define_module_function(mCataract, "parse_media_types", parse_media_types, 1);
1066
1353
  rb_define_module_function(mCataract, "parse_declarations", new_parse_declarations, 1);
1067
1354
  rb_define_module_function(mCataract, "flatten", cataract_flatten, 1);