cataract 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -6
  4. data/.rubocop_todo.yml +11 -19
  5. data/BENCHMARKS.md +40 -40
  6. data/CHANGELOG.md +28 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +312 -550
  11. data/ext/cataract/cataract.h +126 -16
  12. data/ext/cataract/css_parser.c +782 -806
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +26 -10
  18. data/lib/cataract/import_resolver.rb +36 -16
  19. data/lib/cataract/native.rb +30 -0
  20. data/lib/cataract/pure/byte_constants.rb +70 -67
  21. data/lib/cataract/pure/declarations.rb +125 -0
  22. data/lib/cataract/pure/flatten.rb +1195 -1182
  23. data/lib/cataract/pure/parser.rb +1721 -1652
  24. data/lib/cataract/pure/serializer.rb +575 -710
  25. data/lib/cataract/pure/specificity.rb +152 -175
  26. data/lib/cataract/pure.rb +73 -108
  27. data/lib/cataract/rule.rb +6 -3
  28. data/lib/cataract/stylesheet.rb +475 -415
  29. data/lib/cataract/version.rb +1 -1
  30. data/lib/cataract.rb +41 -30
  31. data/lib/tasks/profile.rake +6 -3
  32. metadata +4 -14
  33. data/ext/cataract/import_scanner.c +0 -174
  34. data/ext/cataract_old/cataract.c +0 -393
  35. data/ext/cataract_old/cataract.h +0 -250
  36. data/ext/cataract_old/css_parser.c +0 -933
  37. data/ext/cataract_old/extconf.rb +0 -67
  38. data/ext/cataract_old/import_scanner.c +0 -174
  39. data/ext/cataract_old/merge.c +0 -776
  40. data/ext/cataract_old/shorthand_expander.c +0 -902
  41. data/ext/cataract_old/specificity.c +0 -213
  42. data/ext/cataract_old/stylesheet.c +0 -290
  43. data/ext/cataract_old/value_splitter.c +0 -116
  44. data/lib/cataract/pure/helpers.rb +0 -35
@@ -131,8 +131,10 @@ VALUE parse_css_new(int argc, VALUE *argv, VALUE self) {
131
131
  * @param charset [String, nil] Optional @charset value
132
132
  * @return [String] CSS string
133
133
  */
134
- // Helper to serialize a single rule's declarations
135
- static void serialize_declarations(VALUE result, VALUE declarations) {
134
+ // Helper to serialize a rule's declarations - indent == NULL means compact
135
+ // (semicolon+space separated, single line); non-NULL means one declaration
136
+ // per line, prefixed with indent.
137
+ static void serialize_declarations(VALUE result, VALUE declarations, const char *indent) {
136
138
  long decl_len = RARRAY_LEN(declarations);
137
139
  for (long j = 0; j < decl_len; j++) {
138
140
  VALUE decl = rb_ary_entry(declarations, j);
@@ -140,6 +142,7 @@ static void serialize_declarations(VALUE result, VALUE declarations) {
140
142
  VALUE value = rb_struct_aref(decl, INT2FIX(DECL_VALUE));
141
143
  VALUE important = rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT));
142
144
 
145
+ if (indent) rb_str_cat2(result, indent);
143
146
  rb_str_append(result, property);
144
147
  rb_str_cat2(result, ": ");
145
148
  rb_str_append(result, value);
@@ -148,34 +151,15 @@ static void serialize_declarations(VALUE result, VALUE declarations) {
148
151
  rb_str_cat2(result, " !important");
149
152
  }
150
153
 
151
- rb_str_cat2(result, ";");
152
-
153
- // Add space after semicolon except for last declaration
154
- if (j < decl_len - 1) {
155
- rb_str_cat2(result, " ");
156
- }
157
- }
158
- }
159
-
160
- // Formatted version - each declaration on its own line with indentation
161
- static void serialize_declarations_formatted(VALUE result, VALUE declarations, const char *indent) {
162
- long decl_len = RARRAY_LEN(declarations);
163
- for (long j = 0; j < decl_len; j++) {
164
- VALUE decl = rb_ary_entry(declarations, j);
165
- VALUE property = rb_struct_aref(decl, INT2FIX(DECL_PROPERTY));
166
- VALUE value = rb_struct_aref(decl, INT2FIX(DECL_VALUE));
167
- VALUE important = rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT));
168
-
169
- rb_str_cat2(result, indent);
170
- rb_str_append(result, property);
171
- rb_str_cat2(result, ": ");
172
- rb_str_append(result, value);
173
-
174
- if (RTEST(important)) {
175
- rb_str_cat2(result, " !important");
154
+ if (indent) {
155
+ rb_str_cat2(result, ";\n");
156
+ } else {
157
+ rb_str_cat2(result, ";");
158
+ // Add space after semicolon except for last declaration
159
+ if (j < decl_len - 1) {
160
+ rb_str_cat2(result, " ");
161
+ }
176
162
  }
177
-
178
- rb_str_cat2(result, ";\n");
179
163
  }
180
164
  }
181
165
 
@@ -201,13 +185,13 @@ static void serialize_at_rule(VALUE result, VALUE at_rule) {
201
185
  rb_str_cat2(result, " ");
202
186
  rb_str_append(result, nested_selector);
203
187
  rb_str_cat2(result, " { ");
204
- serialize_declarations(result, nested_declarations);
188
+ serialize_declarations(result, nested_declarations, NULL);
205
189
  rb_str_cat2(result, " }\n");
206
190
  }
207
191
  } else {
208
192
  // Serialize as declarations (e.g., @font-face)
209
193
  rb_str_cat2(result, " ");
210
- serialize_declarations(result, content);
194
+ serialize_declarations(result, content, NULL);
211
195
  rb_str_cat2(result, "\n");
212
196
  }
213
197
  }
@@ -284,7 +268,7 @@ static void serialize_rule(VALUE result, VALUE rule) {
284
268
 
285
269
  rb_str_append(result, selector);
286
270
  rb_str_cat2(result, " { ");
287
- serialize_declarations(result, declarations);
271
+ serialize_declarations(result, declarations, NULL);
288
272
  rb_str_cat2(result, " }\n");
289
273
  }
290
274
 
@@ -318,7 +302,7 @@ static void serialize_at_rule_formatted(VALUE result, VALUE at_rule, const char
318
302
  VALUE nested_indent = rb_str_new_cstr(indent);
319
303
  rb_str_cat2(nested_indent, " ");
320
304
  const char *nested_indent_ptr = RSTRING_PTR(nested_indent);
321
- serialize_declarations_formatted(result, nested_declarations, nested_indent_ptr);
305
+ serialize_declarations(result, nested_declarations, nested_indent_ptr);
322
306
  RB_GC_GUARD(nested_indent);
323
307
 
324
308
  // Closing brace (2-space indent)
@@ -330,7 +314,7 @@ static void serialize_at_rule_formatted(VALUE result, VALUE at_rule, const char
330
314
  VALUE content_indent = rb_str_new_cstr(indent);
331
315
  rb_str_cat2(content_indent, " ");
332
316
  const char *content_indent_ptr = RSTRING_PTR(content_indent);
333
- serialize_declarations_formatted(result, content, content_indent_ptr);
317
+ serialize_declarations(result, content, content_indent_ptr);
334
318
  RB_GC_GUARD(content_indent);
335
319
  }
336
320
  }
@@ -360,7 +344,7 @@ static void serialize_rule_formatted(VALUE result, VALUE rule, const char *inden
360
344
  VALUE decl_indent = rb_str_new_cstr(indent);
361
345
  rb_str_cat2(decl_indent, " ");
362
346
  const char *decl_indent_ptr = RSTRING_PTR(decl_indent);
363
- serialize_declarations_formatted(result, declarations, decl_indent_ptr);
347
+ serialize_declarations(result, declarations, decl_indent_ptr);
364
348
  RB_GC_GUARD(decl_indent);
365
349
 
366
350
  // Closing brace - double newline for all except last rule
@@ -393,6 +377,54 @@ static int build_mq_reverse_map_callback(VALUE list_id, VALUE mq_ids, VALUE arg)
393
377
  return ST_CONTINUE;
394
378
  }
395
379
 
380
+ // Build the media_query_id => list_id reverse map used by both stylesheet
381
+ // serializers (grouping and nesting) to detect when a rule's media query is
382
+ // part of a comma-separated list (e.g. "@media screen, print").
383
+ static VALUE build_mq_id_to_list_id(VALUE media_query_lists) {
384
+ VALUE mq_id_to_list_id = rb_hash_new();
385
+ if (!NIL_P(media_query_lists) && TYPE(media_query_lists) == T_HASH) {
386
+ struct build_mq_reverse_map_ctx ctx = { mq_id_to_list_id };
387
+ rb_hash_foreach(media_query_lists, build_mq_reverse_map_callback, (VALUE)&ctx);
388
+ }
389
+ return mq_id_to_list_id;
390
+ }
391
+
392
+ // Build a rule_id => rule lookup. rules_array is NOT guaranteed to satisfy
393
+ // rules[i].id == i here - that invariant only holds for the full,
394
+ // freshly-parsed rules array; Stylesheet#to_s(media: ...) passes a filtered
395
+ // subset whenever the media filter isn't :all, so a plain array index would
396
+ // silently fetch the wrong rule (or even an AtRule, which doesn't share
397
+ // Rule's struct layout) once any rule has been filtered out. Both Rule and
398
+ // AtRule keep id at field index 0, so a single RULE_ID read is safe for
399
+ // either struct type.
400
+ static VALUE build_rules_by_id(VALUE rules_array) {
401
+ VALUE rules_by_id = rb_hash_new();
402
+ long len = RARRAY_LEN(rules_array);
403
+ for (long i = 0; i < len; i++) {
404
+ VALUE rule = rb_ary_entry(rules_array, i);
405
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
406
+ rb_hash_aset(rules_by_id, rule_id, rule);
407
+ }
408
+ return rules_by_id;
409
+ }
410
+
411
+ // Rule and AtRule don't share a member layout past their first few fields
412
+ // (see the AT_RULE_* comment in cataract.h), so reading parent_rule_id or
413
+ // media_query_id off a rule of unknown type needs to dispatch on which
414
+ // struct it actually is rather than always using Rule's index - otherwise,
415
+ // for an AtRule, RULE_PARENT_RULE_ID's index (4) actually reads its
416
+ // media_query_id field instead.
417
+ static inline VALUE rule_parent_id(VALUE rule) {
418
+ // AtRule instances are never nested via CSS nesting - only Rule can be.
419
+ return rb_obj_is_kind_of(rule, cAtRule) ? Qnil : rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
420
+ }
421
+
422
+ static inline VALUE rule_media_query_id_of(VALUE rule) {
423
+ return rb_obj_is_kind_of(rule, cAtRule)
424
+ ? rb_struct_aref(rule, INT2FIX(AT_RULE_MEDIA_QUERY_ID))
425
+ : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
426
+ }
427
+
396
428
  // Formatting options for stylesheet serialization
397
429
  // Avoids mode flags and if/else branches - all behavior controlled by struct values
398
430
  struct format_opts {
@@ -404,6 +436,126 @@ struct format_opts {
404
436
  int add_blank_lines; // 0 (compact) vs 1 (formatted)
405
437
  };
406
438
 
439
+ // Serialize a single rule, compact or formatted depending on whether
440
+ // decl_indent is NULL - mirrors the format_opts indent-as-mode-flag
441
+ // convention used throughout this file.
442
+ static inline void emit_rule(VALUE result, VALUE rule, const char *decl_indent, const char *rule_prefix) {
443
+ if (decl_indent) {
444
+ serialize_rule_formatted(result, rule, rule_prefix, 1);
445
+ } else {
446
+ serialize_rule(result, rule);
447
+ }
448
+ }
449
+
450
+ // Scan rule_ids_in_list for other not-yet-processed rules that share
451
+ // rule_media_query_id and rule_declarations with the current rule, marking
452
+ // each match as processed. Returns an array of their selectors (does not
453
+ // include the current rule's own selector or mark it processed - the
454
+ // caller's rule_id is handled by serialize_rule_in_group).
455
+ static VALUE collect_matching_selectors(VALUE rule_ids_in_list, VALUE rule_media_query_id, VALUE rule_declarations, VALUE rules_by_id, VALUE processed_rule_ids) {
456
+ VALUE matching_selectors = rb_ary_new();
457
+ long list_len = RARRAY_LEN(rule_ids_in_list);
458
+
459
+ for (long j = 0; j < list_len; j++) {
460
+ VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
461
+
462
+ // Skip if already processed
463
+ if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) {
464
+ continue;
465
+ }
466
+
467
+ // Find the rule by ID
468
+ VALUE other_rule = rb_hash_aref(rules_by_id, other_rule_id);
469
+ if (NIL_P(other_rule)) continue;
470
+
471
+ // Check same media context (compare media_query_id directly)
472
+ VALUE other_rule_media_query_id = rb_struct_aref(other_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
473
+ if (!rb_equal(rule_media_query_id, other_rule_media_query_id)) {
474
+ continue;
475
+ }
476
+
477
+ // Check if declarations match
478
+ VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
479
+ if (rb_equal(rule_declarations, other_declarations)) {
480
+ VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
481
+ rb_ary_push(matching_selectors, other_selector);
482
+ rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
483
+ }
484
+ }
485
+
486
+ return matching_selectors;
487
+ }
488
+
489
+ // Emit a comma-joined group of matching selectors sharing one declaration
490
+ // block, or fall back to a single rule if grouping didn't find any matches.
491
+ // rule_prefix is written before the (possibly joined) selector line and
492
+ // before the closing brace - "" at the top level, opts->media_indent inside
493
+ // a media block (a no-op when "").
494
+ static void emit_grouped_rule(VALUE result, VALUE rule, VALUE matching_selectors, VALUE rule_declarations, const struct format_opts *opts, const char *decl_indent, const char *rule_prefix) {
495
+ if (RARRAY_LEN(matching_selectors) > 1) {
496
+ VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
497
+ rb_str_cat2(result, rule_prefix);
498
+ rb_str_append(result, selector_str);
499
+ rb_str_cat2(result, opts->opening_brace);
500
+ serialize_declarations(result, rule_declarations, decl_indent);
501
+ rb_str_cat2(result, rule_prefix);
502
+ rb_str_cat2(result, opts->closing_brace);
503
+ RB_GC_GUARD(selector_str);
504
+ } else {
505
+ emit_rule(result, rule, decl_indent, rule_prefix);
506
+ }
507
+ }
508
+
509
+ // Loop-invariant state shared by every serialize_rule_in_group call within
510
+ // one serialize_stylesheet_with_grouping invocation - populated once instead
511
+ // of threading 5 params through on every call.
512
+ struct rule_group_ctx {
513
+ VALUE selector_lists;
514
+ int grouping_enabled;
515
+ VALUE rules_by_id;
516
+ VALUE processed_rule_ids;
517
+ const struct format_opts *opts;
518
+ };
519
+
520
+ // Serialize one rule, applying selector-list grouping if enabled. Used
521
+ // identically for both the base (not-in-media) and in-media contexts -
522
+ // in_media selects the relevant format_opts fields ("" and
523
+ // opts->decl_indent_base at the base level; opts->media_indent and
524
+ // opts->decl_indent_media inside a media block).
525
+ static void serialize_rule_in_group(
526
+ VALUE result,
527
+ VALUE rule,
528
+ VALUE rule_id,
529
+ VALUE rule_media_query_id,
530
+ const struct rule_group_ctx *ctx,
531
+ int in_media
532
+ ) {
533
+ const char *decl_indent = in_media ? ctx->opts->decl_indent_media : ctx->opts->decl_indent_base;
534
+ const char *rule_prefix = in_media ? ctx->opts->media_indent : "";
535
+
536
+ // Try to group with other rules from same selector list
537
+ // Check if this is a Rule (not AtRule) before accessing selector_list_id
538
+ if (ctx->grouping_enabled && rb_obj_is_kind_of(rule, cRule)) {
539
+ VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
540
+ if (!NIL_P(selector_list_id)) {
541
+ // Get list of rule IDs in this selector list
542
+ VALUE rule_ids_in_list = rb_hash_aref(ctx->selector_lists, selector_list_id);
543
+
544
+ if (!NIL_P(rule_ids_in_list) && RARRAY_LEN(rule_ids_in_list) > 1) {
545
+ VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
546
+ VALUE matching_selectors = collect_matching_selectors(rule_ids_in_list, rule_media_query_id, rule_declarations, ctx->rules_by_id, ctx->processed_rule_ids);
547
+ emit_grouped_rule(result, rule, matching_selectors, rule_declarations, ctx->opts, decl_indent, rule_prefix);
548
+ return;
549
+ }
550
+ }
551
+ }
552
+
553
+ // No grouping applies (disabled, not a Rule, no selector_list_id, or a
554
+ // selector list of 1) - serialize normally
555
+ emit_rule(result, rule, decl_indent, rule_prefix);
556
+ rb_hash_aset(ctx->processed_rule_ids, rule_id, Qtrue);
557
+ }
558
+
407
559
  // Private shared implementation for stylesheet serialization with optional selector list grouping
408
560
  // All formatting behavior controlled by format_opts struct to avoid mode flags and if/else branches
409
561
  static VALUE serialize_stylesheet_with_grouping(
@@ -421,15 +573,19 @@ static VALUE serialize_stylesheet_with_grouping(
421
573
 
422
574
  // Build reverse map: media_query_id => list_id
423
575
  // This allows us to detect when multiple rules share a comma-separated media query list
424
- VALUE mq_id_to_list_id = rb_hash_new();
425
- if (!NIL_P(media_query_lists) && TYPE(media_query_lists) == T_HASH) {
426
- struct build_mq_reverse_map_ctx ctx = { mq_id_to_list_id };
427
- rb_hash_foreach(media_query_lists, build_mq_reverse_map_callback, (VALUE)&ctx);
428
- }
576
+ VALUE mq_id_to_list_id = build_mq_id_to_list_id(media_query_lists);
577
+
578
+ // Only needed by the grouping path - rules_array here may already be
579
+ // filtered (Stylesheet#to_s(media: ...) for anything but :all), so
580
+ // looking up "other rules in this selector list" needs a real id => rule
581
+ // map rather than indexing directly into rules_array.
582
+ VALUE rules_by_id = grouping_enabled ? build_rules_by_id(rules_array) : Qnil;
429
583
 
430
584
  // Track processed rules to avoid duplicates when grouping
431
585
  VALUE processed_rule_ids = rb_hash_new();
432
586
 
587
+ struct rule_group_ctx group_ctx = { selector_lists, grouping_enabled, rules_by_id, processed_rule_ids, opts };
588
+
433
589
  // Iterate through rules in insertion order, grouping consecutive media queries
434
590
  VALUE current_media = Qnil;
435
591
  int in_media_block = 0;
@@ -443,8 +599,8 @@ static VALUE serialize_stylesheet_with_grouping(
443
599
  continue;
444
600
  }
445
601
 
446
- // Get media_query_id and fetch MediaQuery object (nil for AtRule or rules without media query)
447
- VALUE rule_media_query_id = rb_obj_is_kind_of(rule, cAtRule) ? Qnil : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
602
+ // Get media_query_id and fetch MediaQuery object (nil for rules without media query)
603
+ VALUE rule_media_query_id = rule_media_query_id_of(rule);
448
604
  VALUE rule_media = Qnil;
449
605
  if (!NIL_P(rule_media_query_id)) {
450
606
  rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
@@ -464,95 +620,7 @@ static VALUE serialize_stylesheet_with_grouping(
464
620
  rb_str_cat2(result, "\n");
465
621
  }
466
622
 
467
- // Try to group with other rules from same selector list
468
- // Check if this is a Rule (not AtRule) before accessing selector_list_id
469
- if (grouping_enabled && rb_obj_is_kind_of(rule, cRule)) {
470
- VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
471
- if (!NIL_P(selector_list_id)) {
472
- // Get list of rule IDs in this selector list
473
- VALUE rule_ids_in_list = rb_hash_aref(selector_lists, selector_list_id);
474
-
475
- if (NIL_P(rule_ids_in_list) || RARRAY_LEN(rule_ids_in_list) <= 1) {
476
- // Just this rule, serialize normally
477
- if (opts->decl_indent_base) {
478
- serialize_rule_formatted(result, rule, "", 1);
479
- } else {
480
- serialize_rule(result, rule);
481
- }
482
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
483
- } else {
484
- // Find all rules with matching declarations and same media context
485
- VALUE matching_selectors = rb_ary_new();
486
- VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
487
-
488
- long list_len = RARRAY_LEN(rule_ids_in_list);
489
- for (long j = 0; j < list_len; j++) {
490
- VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
491
-
492
- // Skip if already processed
493
- if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) {
494
- continue;
495
- }
496
-
497
- // Find the rule by ID
498
- VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
499
- if (NIL_P(other_rule)) continue;
500
-
501
- // Check same media context (compare media_query_id directly)
502
- VALUE other_rule_media_query_id = rb_struct_aref(other_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
503
- if (!rb_equal(rule_media_query_id, other_rule_media_query_id)) {
504
- continue;
505
- }
506
-
507
- // Check if declarations match
508
- VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
509
- if (rb_equal(rule_declarations, other_declarations)) {
510
- VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
511
- rb_ary_push(matching_selectors, other_selector);
512
- rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
513
- }
514
- }
515
-
516
- // Serialize grouped or single rule
517
- if (RARRAY_LEN(matching_selectors) > 1) {
518
- // Group selectors with comma-space separator
519
- VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
520
- rb_str_append(result, selector_str);
521
- rb_str_cat2(result, opts->opening_brace);
522
- if (opts->decl_indent_base) {
523
- serialize_declarations_formatted(result, rule_declarations, opts->decl_indent_base);
524
- } else {
525
- serialize_declarations(result, rule_declarations);
526
- }
527
- rb_str_cat2(result, opts->closing_brace);
528
- RB_GC_GUARD(selector_str);
529
- } else {
530
- // Just one rule, serialize normally
531
- if (opts->decl_indent_base) {
532
- serialize_rule_formatted(result, rule, "", 1);
533
- } else {
534
- serialize_rule(result, rule);
535
- }
536
- }
537
- }
538
- } else {
539
- // No selector_list_id, serialize normally
540
- if (opts->decl_indent_base) {
541
- serialize_rule_formatted(result, rule, "", 1);
542
- } else {
543
- serialize_rule(result, rule);
544
- }
545
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
546
- }
547
- } else {
548
- // Grouping disabled, serialize normally
549
- if (opts->decl_indent_base) {
550
- serialize_rule_formatted(result, rule, "", 1);
551
- } else {
552
- serialize_rule(result, rule);
553
- }
554
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
555
- }
623
+ serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 0);
556
624
  } else {
557
625
  // This rule is in a media query
558
626
  // Check if media query changed from previous rule (compare MediaQuery objects by value)
@@ -580,80 +648,7 @@ static VALUE serialize_stylesheet_with_grouping(
580
648
  in_media_block = 1;
581
649
  }
582
650
 
583
- // Serialize rule inside media block (with grouping if enabled)
584
- // Check if this is a Rule (not AtRule) before accessing selector_list_id
585
- if (grouping_enabled && rb_obj_is_kind_of(rule, cRule)) {
586
- VALUE selector_list_id = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR_LIST_ID));
587
- if (!NIL_P(selector_list_id)) {
588
- VALUE rule_ids_in_list = rb_hash_aref(selector_lists, selector_list_id);
589
-
590
- if (NIL_P(rule_ids_in_list) || RARRAY_LEN(rule_ids_in_list) <= 1) {
591
- if (opts->decl_indent_media) {
592
- serialize_rule_formatted(result, rule, opts->media_indent, 1);
593
- } else {
594
- serialize_rule(result, rule);
595
- }
596
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
597
- } else {
598
- VALUE matching_selectors = rb_ary_new();
599
- VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
600
-
601
- long list_len = RARRAY_LEN(rule_ids_in_list);
602
- for (long j = 0; j < list_len; j++) {
603
- VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
604
- if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) continue;
605
-
606
- VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
607
- if (NIL_P(other_rule)) continue;
608
-
609
- VALUE other_rule_media_query_id = rb_struct_aref(other_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
610
- if (!rb_equal(rule_media_query_id, other_rule_media_query_id)) continue;
611
-
612
- VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
613
- if (rb_equal(rule_declarations, other_declarations)) {
614
- VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
615
- rb_ary_push(matching_selectors, other_selector);
616
- rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
617
- }
618
- }
619
-
620
- if (RARRAY_LEN(matching_selectors) > 1) {
621
- VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
622
- rb_str_cat2(result, opts->media_indent);
623
- rb_str_append(result, selector_str);
624
- rb_str_cat2(result, opts->opening_brace);
625
- if (opts->decl_indent_media) {
626
- serialize_declarations_formatted(result, rule_declarations, opts->decl_indent_media);
627
- } else {
628
- serialize_declarations(result, rule_declarations);
629
- }
630
- rb_str_cat2(result, opts->media_indent);
631
- rb_str_cat2(result, opts->closing_brace);
632
- RB_GC_GUARD(selector_str);
633
- } else {
634
- if (opts->decl_indent_media) {
635
- serialize_rule_formatted(result, rule, opts->media_indent, 1);
636
- } else {
637
- serialize_rule(result, rule);
638
- }
639
- }
640
- }
641
- } else {
642
- if (opts->decl_indent_media) {
643
- serialize_rule_formatted(result, rule, opts->media_indent, 1);
644
- } else {
645
- serialize_rule(result, rule);
646
- }
647
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
648
- }
649
- } else {
650
- if (opts->decl_indent_media) {
651
- serialize_rule_formatted(result, rule, opts->media_indent, 1);
652
- } else {
653
- serialize_rule(result, rule);
654
- }
655
- rb_hash_aset(processed_rule_ids, rule_id, Qtrue);
656
- }
651
+ serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 1);
657
652
  }
658
653
  }
659
654
 
@@ -664,37 +659,11 @@ static VALUE serialize_stylesheet_with_grouping(
664
659
 
665
660
  // Guard hash objects we created and used throughout
666
661
  RB_GC_GUARD(mq_id_to_list_id);
662
+ RB_GC_GUARD(rules_by_id);
667
663
  RB_GC_GUARD(processed_rule_ids);
668
664
  return result;
669
665
  }
670
666
 
671
- // Original stylesheet serialization (no nesting support) - compact format
672
- static VALUE stylesheet_to_s_without_nesting(VALUE rules_array, VALUE media_queries, VALUE media_query_lists, VALUE charset, VALUE selector_lists) {
673
- Check_Type(rules_array, T_ARRAY);
674
- Check_Type(media_queries, T_ARRAY);
675
-
676
- VALUE result = rb_str_new_cstr("");
677
-
678
- // Add charset if present
679
- if (!NIL_P(charset)) {
680
- rb_str_cat2(result, "@charset \"");
681
- rb_str_append(result, charset);
682
- rb_str_cat2(result, "\";\n");
683
- }
684
-
685
- // Compact formatting options
686
- struct format_opts opts = {
687
- .opening_brace = " { ",
688
- .closing_brace = " }\n",
689
- .media_indent = "",
690
- .decl_indent_base = NULL,
691
- .decl_indent_media = NULL,
692
- .add_blank_lines = 0
693
- };
694
-
695
- return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, &opts);
696
- }
697
-
698
667
  // Forward declarations
699
668
  static void serialize_children_only(VALUE result, VALUE rules_array, long rule_idx,
700
669
  VALUE parent_to_children, VALUE parent_selector,
@@ -766,7 +735,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
766
735
  rb_str_cat2(child_indent, " ");
767
736
  }
768
737
  const char *child_indent_ptr = RSTRING_PTR(child_indent);
769
- serialize_declarations_formatted(result, child_declarations, child_indent_ptr);
738
+ serialize_declarations(result, child_declarations, child_indent_ptr);
770
739
  RB_GC_GUARD(child_indent);
771
740
  }
772
741
 
@@ -787,7 +756,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
787
756
 
788
757
  // Serialize child declarations
789
758
  VALUE child_declarations = rb_struct_aref(child, INT2FIX(RULE_DECLARATIONS));
790
- serialize_declarations(result, child_declarations);
759
+ serialize_declarations(result, child_declarations, NULL);
791
760
 
792
761
  // Recursively serialize grandchildren
793
762
  serialize_children_only(result, rules_array, child_idx, parent_to_children,
@@ -823,7 +792,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
823
792
  rb_str_cat2(child_indent, " ");
824
793
  }
825
794
  const char *child_indent_ptr = RSTRING_PTR(child_indent);
826
- serialize_declarations_formatted(result, child_declarations, child_indent_ptr);
795
+ serialize_declarations(result, child_declarations, child_indent_ptr);
827
796
  RB_GC_GUARD(child_indent);
828
797
  }
829
798
 
@@ -835,7 +804,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
835
804
  rb_str_cat2(result, " { ");
836
805
 
837
806
  VALUE child_declarations = rb_struct_aref(child, INT2FIX(RULE_DECLARATIONS));
838
- serialize_declarations(result, child_declarations);
807
+ serialize_declarations(result, child_declarations, NULL);
839
808
 
840
809
  rb_str_cat2(result, " }");
841
810
  }
@@ -876,7 +845,7 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
876
845
  if (!NIL_P(declarations) && RARRAY_LEN(declarations) > 0) {
877
846
  DEBUG_PRINTF("[SERIALIZE_RULE] Serializing %ld declarations with indent='%s' (%d spaces)\n",
878
847
  RARRAY_LEN(declarations), decl_indent, decl_spaces);
879
- serialize_declarations_formatted(result, declarations, decl_indent);
848
+ serialize_declarations(result, declarations, decl_indent);
880
849
  }
881
850
 
882
851
  // Serialize nested children
@@ -891,7 +860,7 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
891
860
  rb_str_cat2(result, " { ");
892
861
 
893
862
  // Serialize own declarations
894
- serialize_declarations(result, declarations);
863
+ serialize_declarations(result, declarations, NULL);
895
864
 
896
865
  // Serialize nested children
897
866
  serialize_children_only(result, rules_array, rule_idx, parent_to_children,
@@ -904,50 +873,31 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
904
873
  RB_GC_GUARD(rule);
905
874
  }
906
875
 
907
- // New stylesheet serialization entry point - checks for nesting and delegates
908
- static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
909
- DEBUG_PRINTF("[STYLESHEET_TO_S] Called with:\n");
910
- DEBUG_PRINTF(" rules_array length: %ld\n", RARRAY_LEN(rules_array));
911
- DEBUG_PRINTF(" media_queries type: %s, length: %ld\n",
912
- rb_obj_classname(media_queries),
913
- TYPE(media_queries) == T_ARRAY ? RARRAY_LEN(media_queries) : -1);
914
- DEBUG_PRINTF(" media_queries inspect: %s\n", RSTRING_PTR(rb_inspect(media_queries)));
915
- DEBUG_PRINTF(" media_query_lists class: %s\n", rb_obj_classname(media_query_lists));
916
- DEBUG_PRINTF(" selector_lists class: %s\n", rb_obj_classname(selector_lists));
917
-
918
- DEBUG_PRINTF("[STYLESHEET_TO_S] About to Check_Type\n");
919
- Check_Type(rules_array, T_ARRAY);
920
- Check_Type(media_queries, T_ARRAY);
921
- if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
922
- if (!NIL_P(selector_lists)) Check_Type(selector_lists, T_HASH);
923
- DEBUG_PRINTF("[STYLESHEET_TO_S] Check_Type passed\n");
924
- // TODO: Phase 2 - use selector_lists for grouping
925
- (void)selector_lists; // Suppress unused parameter warning
926
-
927
- // Fast path: if no nesting, use original implementation (zero overhead)
928
- if (!RTEST(has_nesting)) {
929
- DEBUG_PRINTF("[STYLESHEET_TO_S] Taking fast path (no nesting)\n");
930
- return stylesheet_to_s_without_nesting(rules_array, media_queries, media_query_lists, charset, selector_lists);
931
- }
932
-
933
- DEBUG_PRINTF("[STYLESHEET_TO_S] Taking slow path (has nesting)\n");
934
- // SLOW PATH: Has nesting - use lookahead approach
876
+ // Shared implementation for stylesheet serialization with nesting support.
877
+ // Handles both compact (to_s) and formatted (to_formatted_s) output via the
878
+ // format_opts struct, mirroring the pattern serialize_stylesheet_with_grouping
879
+ // uses for the no-nesting fast path.
880
+ static VALUE serialize_stylesheet_with_nesting(
881
+ VALUE rules_array,
882
+ VALUE media_queries,
883
+ VALUE media_query_lists,
884
+ VALUE result,
885
+ const struct format_opts *opts
886
+ ) {
887
+ int formatted = (opts->decl_indent_base != NULL);
935
888
  long total_rules = RARRAY_LEN(rules_array);
936
- VALUE result = rb_str_new_cstr("");
937
889
 
938
- // Add charset if present
939
- if (!NIL_P(charset)) {
940
- rb_str_cat2(result, "@charset \"");
941
- rb_str_append(result, charset);
942
- rb_str_cat2(result, "\";\n");
943
- }
890
+ // Reverse map: media_query_id => list_id, so a rule whose media query is
891
+ // part of a comma-separated list (e.g. "@media screen, print") renders
892
+ // the full list, not just its own single media query.
893
+ VALUE mq_id_to_list_id = build_mq_id_to_list_id(media_query_lists);
944
894
 
945
895
  // Build parent_to_children map (parent_rule_id -> array of child indices)
946
896
  // This allows O(1) lookup of children when serializing each parent
947
897
  VALUE parent_to_children = rb_hash_new();
948
898
  for (long i = 0; i < total_rules; i++) {
949
899
  VALUE rule = rb_ary_entry(rules_array, i);
950
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
900
+ VALUE parent_id = rule_parent_id(rule);
951
901
 
952
902
  if (!NIL_P(parent_id)) {
953
903
  DEBUG_PRINTF("[MAP] Rule %ld has parent_id=%s, adding to map\n", i,
@@ -973,7 +923,7 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
973
923
  DEBUG_PRINTF("[SERIALIZE] Starting serialization, total_rules=%ld\n", total_rules);
974
924
  for (long i = 0; i < total_rules; i++) {
975
925
  VALUE rule = rb_ary_entry(rules_array, i);
976
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
926
+ VALUE parent_id = rule_parent_id(rule);
977
927
 
978
928
  DEBUG_PRINTF("[SERIALIZE] Rule %ld: selector=%s, parent_id=%s\n", i,
979
929
  RSTRING_PTR(rb_struct_aref(rule, INT2FIX(RULE_SELECTOR))),
@@ -986,7 +936,7 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
986
936
  }
987
937
 
988
938
  // Get media_query_id for this rule and fetch the MediaQuery object
989
- VALUE rule_media_query_id = rb_obj_is_kind_of(rule, cAtRule) ? Qnil : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
939
+ VALUE rule_media_query_id = rule_media_query_id_of(rule);
990
940
  VALUE rule_media = Qnil;
991
941
  if (!NIL_P(rule_media_query_id)) {
992
942
  rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
@@ -1002,6 +952,10 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1002
952
  rb_str_cat2(result, "}\n");
1003
953
  in_media_block = 0;
1004
954
  current_media = Qnil;
955
+
956
+ if (opts->add_blank_lines) {
957
+ rb_str_cat2(result, "\n");
958
+ }
1005
959
  }
1006
960
  } else {
1007
961
  // In media - check if we need to open/change block (compare MediaQuery objects by value)
@@ -1009,11 +963,14 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1009
963
  // Close previous media block if open
1010
964
  if (in_media_block) {
1011
965
  rb_str_cat2(result, "}\n");
966
+ } else if (opts->add_blank_lines && RSTRING_LEN(result) > 0) {
967
+ // Add blank line before new media block (except at start)
968
+ rb_str_cat2(result, "\n");
1012
969
  }
1013
970
  // Open new media block - store the MediaQuery object for comparison
1014
971
  current_media = rule_media;
1015
972
  rb_str_cat2(result, "@media ");
1016
- append_media_query_text(result, rule_media);
973
+ append_media_query_string(result, rule_media_query_id, mq_id_to_list_id, media_query_lists, media_queries);
1017
974
  rb_str_cat2(result, " {\n");
1018
975
  in_media_block = 1;
1019
976
  }
@@ -1025,11 +982,18 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1025
982
  continue;
1026
983
  }
1027
984
 
985
+ // Add indent if inside media block (no-op in compact mode, where media_indent is "")
986
+ if (in_media_block) {
987
+ DEBUG_PRINTF("[FORMATTED] Adding base indent for media block\n");
988
+ rb_str_cat2(result, opts->media_indent);
989
+ }
990
+
1028
991
  // Serialize rule with nested children
992
+ DEBUG_PRINTF("[FORMATTED] Calling serialize_rule_with_children, in_media_block=%d\n", in_media_block);
1029
993
  serialize_rule_with_children(
1030
994
  result, rules_array, i, parent_to_children, media_queries,
1031
- 0, // formatted (compact)
1032
- 0 // indent_level (top-level)
995
+ formatted,
996
+ in_media_block ? 1 : 0 // indent_level (unused when !formatted)
1033
997
  );
1034
998
  }
1035
999
 
@@ -1042,47 +1006,16 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1042
1006
  return result;
1043
1007
  }
1044
1008
 
1045
- // Original formatted serialization (no nesting support)
1046
- static VALUE stylesheet_to_formatted_s_without_nesting(VALUE rules_array, VALUE media_queries, VALUE media_query_lists, VALUE charset, VALUE selector_lists) {
1047
- Check_Type(rules_array, T_ARRAY);
1048
- Check_Type(media_queries, T_ARRAY);
1049
-
1050
- VALUE result = rb_str_new_cstr("");
1051
-
1052
- // Add charset if present
1053
- if (!NIL_P(charset)) {
1054
- rb_str_cat2(result, "@charset \"");
1055
- rb_str_append(result, charset);
1056
- rb_str_cat2(result, "\";\n");
1057
- }
1058
-
1059
- // Formatted output options
1060
- struct format_opts opts = {
1061
- .opening_brace = " {\n",
1062
- .closing_brace = "}\n",
1063
- .media_indent = " ",
1064
- .decl_indent_base = " ",
1065
- .decl_indent_media = " ",
1066
- .add_blank_lines = 1
1067
- };
1068
-
1069
- return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, &opts);
1070
- }
1071
-
1072
- // Formatted version with indentation and newlines (with nesting support)
1073
- static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
1009
+ // Shared implementation for stylesheet_to_s/stylesheet_to_formatted_s - both wrap
1010
+ // this, differing only in which format_opts they select. Dispatches on has_nesting
1011
+ // to pick the selector-list-grouping serializer (no nesting - zero overhead) or the
1012
+ // parent/child lookahead serializer (has nesting).
1013
+ static VALUE stylesheet_serialize_impl(VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists, int formatted) {
1074
1014
  Check_Type(rules_array, T_ARRAY);
1075
1015
  Check_Type(media_queries, T_ARRAY);
1076
1016
  if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
1077
1017
  if (!NIL_P(selector_lists)) Check_Type(selector_lists, T_HASH);
1078
1018
 
1079
- // Fast path: if no nesting, use original implementation (zero overhead)
1080
- if (!RTEST(has_nesting)) {
1081
- return stylesheet_to_formatted_s_without_nesting(rules_array, media_queries, media_query_lists, charset, selector_lists);
1082
- }
1083
-
1084
- // SLOW PATH: Has nesting - use parameterized serialization with formatted=1
1085
- long total_rules = RARRAY_LEN(rules_array);
1086
1019
  VALUE result = rb_str_new_cstr("");
1087
1020
 
1088
1021
  // Add charset if present
@@ -1092,101 +1025,45 @@ static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE char
1092
1025
  rb_str_cat2(result, "\";\n");
1093
1026
  }
1094
1027
 
1095
- // Build parent_to_children map (parent_rule_id -> array of child indices)
1096
- VALUE parent_to_children = rb_hash_new();
1097
- for (long i = 0; i < total_rules; i++) {
1098
- VALUE rule = rb_ary_entry(rules_array, i);
1099
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
1100
-
1101
- if (!NIL_P(parent_id)) {
1102
- VALUE children = rb_hash_aref(parent_to_children, parent_id);
1103
- if (NIL_P(children)) {
1104
- children = rb_ary_new();
1105
- rb_hash_aset(parent_to_children, parent_id, children);
1106
- }
1107
- rb_ary_push(children, LONG2FIX(i));
1108
- }
1028
+ struct format_opts opts;
1029
+ if (formatted) {
1030
+ opts = (struct format_opts){
1031
+ .opening_brace = " {\n",
1032
+ .closing_brace = "}\n",
1033
+ .media_indent = " ",
1034
+ .decl_indent_base = " ",
1035
+ .decl_indent_media = " ",
1036
+ .add_blank_lines = 1
1037
+ };
1038
+ } else {
1039
+ opts = (struct format_opts){
1040
+ .opening_brace = " { ",
1041
+ .closing_brace = " }\n",
1042
+ .media_indent = "",
1043
+ .decl_indent_base = NULL,
1044
+ .decl_indent_media = NULL,
1045
+ .add_blank_lines = 0
1046
+ };
1109
1047
  }
1110
1048
 
1111
- // Track media block state for proper opening/closing
1112
- VALUE current_media = Qnil;
1113
- int in_media_block = 0;
1114
-
1115
- // Serialize only top-level rules (parent_rule_id == nil)
1116
- for (long i = 0; i < total_rules; i++) {
1117
- VALUE rule = rb_ary_entry(rules_array, i);
1118
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
1119
-
1120
- // Skip child rules - they're serialized when we hit their parent
1121
- if (!NIL_P(parent_id)) {
1122
- continue;
1123
- }
1124
-
1125
- // Get media_query_id for this rule and fetch the MediaQuery object
1126
- VALUE rule_media_query_id = rb_obj_is_kind_of(rule, cAtRule) ? Qnil : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
1127
- VALUE rule_media = Qnil;
1128
- if (!NIL_P(rule_media_query_id)) {
1129
- rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
1130
- }
1131
-
1132
- // Handle media block transitions
1133
- if (NIL_P(rule_media)) {
1134
- // Not in media - close any open media block
1135
- if (in_media_block) {
1136
- rb_str_cat2(result, "}\n");
1137
- in_media_block = 0;
1138
- current_media = Qnil;
1139
-
1140
- // Add blank line after closing media block
1141
- rb_str_cat2(result, "\n");
1142
- }
1143
- } else {
1144
- // In media - check if we need to open/change block (compare MediaQuery objects by value)
1145
- if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
1146
- // Close previous media block if open
1147
- if (in_media_block) {
1148
- rb_str_cat2(result, "}\n");
1149
- } else if (RSTRING_LEN(result) > 0) {
1150
- // Add blank line before new media block (except at start)
1151
- rb_str_cat2(result, "\n");
1152
- }
1153
- // Open new media block - store the MediaQuery object for comparison
1154
- current_media = rule_media;
1155
- rb_str_cat2(result, "@media ");
1156
- append_media_query_text(result, rule_media);
1157
- rb_str_cat2(result, " {\n");
1158
- in_media_block = 1;
1159
- }
1160
- }
1161
-
1162
- // Check if this is an AtRule
1163
- if (rb_obj_is_kind_of(rule, cAtRule)) {
1164
- serialize_at_rule(result, rule);
1165
- continue;
1166
- }
1167
-
1168
- // Add indent if inside media block
1169
- if (in_media_block) {
1170
- DEBUG_PRINTF("[FORMATTED] Adding base indent for media block\n");
1171
- rb_str_cat2(result, " ");
1172
- }
1173
-
1174
- // Serialize rule with nested children
1175
- DEBUG_PRINTF("[FORMATTED] Calling serialize_rule_with_children, in_media_block=%d\n", in_media_block);
1176
- serialize_rule_with_children(
1177
- result, rules_array, i, parent_to_children, media_queries,
1178
- 1, // formatted (with indentation)
1179
- in_media_block ? 1 : 0 // indent_level (1 if inside media block, 0 otherwise)
1180
- );
1049
+ // Fast path: if no nesting, use the selector-list-grouping serializer (zero overhead)
1050
+ if (!RTEST(has_nesting)) {
1051
+ return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, &opts);
1181
1052
  }
1182
1053
 
1183
- // Close final media block if still open
1184
- if (in_media_block) {
1185
- rb_str_cat2(result, "}\n");
1186
- }
1054
+ // Has nesting - use the parent/child lookahead serializer
1055
+ // TODO: Phase 2 - use selector_lists for grouping on the nesting path too
1056
+ return serialize_stylesheet_with_nesting(rules_array, media_queries, media_query_lists, result, &opts);
1057
+ }
1187
1058
 
1188
- RB_GC_GUARD(parent_to_children);
1189
- return result;
1059
+ // New stylesheet serialization entry point - checks for nesting and delegates
1060
+ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
1061
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 0);
1062
+ }
1063
+
1064
+ // Formatted version with indentation and newlines (with nesting support)
1065
+ static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
1066
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 1);
1190
1067
  }
1191
1068
 
1192
1069
  /*
@@ -1207,62 +1084,18 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1207
1084
  while (pos < end && (IS_WHITESPACE(*pos) || *pos == ';')) pos++;
1208
1085
  if (pos >= end) break;
1209
1086
 
1210
- // Find property (up to colon)
1211
- const char *prop_start = pos;
1212
- while (pos < end && *pos != ':') pos++;
1213
- if (pos >= end) break; // No colon found
1214
-
1215
- const char *prop_end = pos;
1216
- // Trim trailing whitespace
1217
- while (prop_end > prop_start && IS_WHITESPACE(*(prop_end-1))) prop_end--;
1218
- // Trim leading whitespace
1219
- while (prop_start < prop_end && IS_WHITESPACE(*prop_start)) prop_start++;
1220
-
1221
- pos++; // Skip colon
1222
- // Trim leading whitespace
1223
- while (pos < end && IS_WHITESPACE(*pos)) pos++;
1224
-
1225
- // Find value (up to semicolon or end), handling parentheses
1226
- const char *val_start = pos;
1227
- int paren_depth = 0;
1228
- while (pos < end) {
1229
- if (*pos == '(') paren_depth++;
1230
- else if (*pos == ')') paren_depth--;
1231
- else if (*pos == ';' && paren_depth == 0) break;
1232
- pos++;
1233
- }
1234
- const char *val_end = pos;
1235
- // Trim trailing whitespace
1236
- while (val_end > val_start && IS_WHITESPACE(*(val_end-1))) val_end--;
1237
-
1238
- // Check for !important
1239
- int is_important = 0;
1240
- if (val_end - val_start >= 10) { // strlen("!important") = 10
1241
- const char *check = val_end - 10;
1242
- while (check < val_end && IS_WHITESPACE(*check)) check++;
1243
- if (check < val_end && *check == '!') {
1244
- check++;
1245
- while (check < val_end && IS_WHITESPACE(*check)) check++;
1246
- if ((val_end - check) >= 9 && strncmp(check, "important", 9) == 0) {
1247
- is_important = 1;
1248
- const char *important_pos = check - 1;
1249
- while (important_pos > val_start && (IS_WHITESPACE(*(important_pos-1)) || *(important_pos-1) == '!')) {
1250
- important_pos--;
1251
- }
1252
- val_end = important_pos;
1253
- // Trim trailing whitespace again
1254
- while (val_end > val_start && IS_WHITESPACE(*(val_end-1))) val_end--;
1255
- }
1256
- }
1257
- }
1087
+ // Standalone declaration-list input never contains braces, so only
1088
+ // ':' terminates the property scan (stop_prop_scan_early=0).
1089
+ struct declaration_span span;
1090
+ if (!parse_one_declaration(&pos, end, 0, &span)) break; // No colon found
1258
1091
 
1259
1092
  // Skip if value is empty
1260
- if (val_end > val_start) {
1261
- long prop_len = prop_end - prop_start;
1262
- long val_len = val_end - val_start;
1093
+ if (span.val_end > span.val_start) {
1094
+ long prop_len = span.prop_end - span.prop_start;
1095
+ long val_len = span.val_end - span.val_start;
1263
1096
 
1264
1097
  // Create property string (US-ASCII, lowercased)
1265
- VALUE property = rb_usascii_str_new(prop_start, prop_len);
1098
+ VALUE property = rb_usascii_str_new(span.prop_start, prop_len);
1266
1099
  // Lowercase it inline
1267
1100
  char *prop_ptr = RSTRING_PTR(property);
1268
1101
  for (long i = 0; i < prop_len; i++) {
@@ -1271,11 +1104,11 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1271
1104
  }
1272
1105
  }
1273
1106
 
1274
- VALUE value = rb_utf8_str_new(val_start, val_len);
1107
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
1275
1108
 
1276
1109
  // Create Declaration struct
1277
1110
  VALUE decl = rb_struct_new(cDeclaration,
1278
- property, value, is_important ? Qtrue : Qfalse);
1111
+ property, value, span.is_important ? Qtrue : Qfalse);
1279
1112
 
1280
1113
  rb_ary_push(declarations, decl);
1281
1114
  }
@@ -1284,78 +1117,6 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1284
1117
  return declarations;
1285
1118
  }
1286
1119
 
1287
- /*
1288
- * Convert array of Declaration structs to CSS string
1289
- * Format: "prop: value; prop2: value2 !important; "
1290
- *
1291
- * This is a copy of declarations_array_to_s from cataract.c,
1292
- * but works with Declaration structs instead of Declaration structs
1293
- */
1294
- static VALUE new_declarations_array_to_s(VALUE declarations_array) {
1295
- Check_Type(declarations_array, T_ARRAY);
1296
-
1297
- long len = RARRAY_LEN(declarations_array);
1298
- if (len == 0) {
1299
- return rb_str_new_cstr("");
1300
- }
1301
-
1302
- // Use rb_str_buf_new for efficient string building
1303
- VALUE result = rb_str_buf_new(len * 32); // Estimate 32 chars per declaration
1304
-
1305
- for (long i = 0; i < len; i++) {
1306
- VALUE decl = rb_ary_entry(declarations_array, i);
1307
-
1308
- // Validate this is a Declaration struct
1309
- if (!RB_TYPE_P(decl, T_STRUCT) || rb_obj_class(decl) != cDeclaration) {
1310
- rb_raise(rb_eTypeError,
1311
- "Expected array of Declaration structs, got %s at index %ld",
1312
- rb_obj_classname(decl), i);
1313
- }
1314
-
1315
- // Extract struct fields
1316
- VALUE property = rb_struct_aref(decl, INT2FIX(DECL_PROPERTY));
1317
- VALUE value = rb_struct_aref(decl, INT2FIX(DECL_VALUE));
1318
- VALUE important = rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT));
1319
-
1320
- // Append: "property: value"
1321
- rb_str_buf_append(result, property);
1322
- rb_str_buf_cat2(result, ": ");
1323
- rb_str_buf_append(result, value);
1324
-
1325
- // Append " !important" if needed
1326
- if (RTEST(important)) {
1327
- rb_str_buf_cat2(result, " !important");
1328
- }
1329
-
1330
- rb_str_buf_cat2(result, "; ");
1331
-
1332
- RB_GC_GUARD(decl);
1333
- RB_GC_GUARD(property);
1334
- RB_GC_GUARD(value);
1335
- RB_GC_GUARD(important);
1336
- }
1337
-
1338
- // Strip trailing space
1339
- rb_str_set_len(result, RSTRING_LEN(result) - 1);
1340
-
1341
- RB_GC_GUARD(result);
1342
- return result;
1343
- }
1344
-
1345
- /*
1346
- * Instance method: Declarations#to_s
1347
- * Converts declarations to CSS string
1348
- *
1349
- * @return [String] CSS declarations like "color: red; margin: 10px !important;"
1350
- */
1351
- static VALUE new_declarations_to_s_method(VALUE self) {
1352
- // Get @values instance variable (array of Declaration structs)
1353
- VALUE values = rb_ivar_get(self, rb_intern("@values"));
1354
-
1355
- // Call core serialization function
1356
- return new_declarations_array_to_s(values);
1357
- }
1358
-
1359
1120
  /*
1360
1121
  * Ruby-facing wrapper for new_parse_declarations
1361
1122
  *
@@ -1446,23 +1207,24 @@ void Init_native_extension(void) {
1446
1207
  rb_raise(rb_eLoadError, "Cataract::MediaQuery not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1447
1208
  }
1448
1209
 
1449
- // Define Declarations class and add to_s method
1450
- VALUE cDeclarations = rb_define_class_under(mCataract, "Declarations", rb_cObject);
1451
- rb_define_method(cDeclarations, "to_s", new_declarations_to_s_method, 0);
1452
-
1453
- // Define Stylesheet class (Ruby will add instance methods like each_selector)
1210
+ // Define Stylesheet class (Ruby will add instance methods like each_selector).
1211
+ // Declarations is left alone here - it's a plain Ruby class (declarations.rb)
1212
+ // with its own #to_s, not something this backend attaches methods to.
1454
1213
  cStylesheet = rb_define_class_under(mCataract, "Stylesheet", rb_cObject);
1455
1214
 
1456
- // Define module functions
1457
- rb_define_module_function(mCataract, "_parse_css", parse_css_new, -1);
1458
- rb_define_module_function(mCataract, "stylesheet_to_s", stylesheet_to_s, 6);
1459
- rb_define_module_function(mCataract, "stylesheet_to_formatted_s", stylesheet_to_formatted_s, 6);
1460
- rb_define_module_function(mCataract, "parse_media_types", parse_media_types, 1);
1461
- rb_define_module_function(mCataract, "parse_declarations", new_parse_declarations, 1);
1462
- rb_define_module_function(mCataract, "flatten", cataract_flatten, 1);
1463
- rb_define_module_function(mCataract, "merge", cataract_flatten, 1); // Deprecated alias for backwards compatibility
1464
- rb_define_module_function(mCataract, "calculate_specificity", calculate_specificity, 1);
1465
- rb_define_module_function(mCataract, "expand_shorthand", cataract_expand_shorthand, 1);
1215
+ // Every native-specific entry point lives under Cataract::Backends::Native,
1216
+ // never directly on Cataract itself, so the pure Ruby backend can be loaded
1217
+ // in the same process without either backend clobbering the other's methods.
1218
+ VALUE mBackends = rb_define_module_under(mCataract, "Backends");
1219
+ VALUE mNative = rb_define_module_under(mBackends, "Native");
1220
+
1221
+ rb_define_module_function(mNative, "parse", parse_css_new, -1);
1222
+ rb_define_module_function(mNative, "stylesheet_to_s", stylesheet_to_s, 6);
1223
+ rb_define_module_function(mNative, "stylesheet_to_formatted_s", stylesheet_to_formatted_s, 6);
1224
+ rb_define_module_function(mNative, "parse_declarations", new_parse_declarations, 1);
1225
+ rb_define_module_function(mNative, "flatten", cataract_flatten, 1);
1226
+ rb_define_module_function(mNative, "calculate_specificity", calculate_specificity, 1);
1227
+ rb_define_module_function(mNative, "expand_shorthand", cataract_expand_shorthand, 1);
1466
1228
 
1467
1229
  // Initialize flatten constants (cached property strings)
1468
1230
  init_flatten_constants();
@@ -1482,11 +1244,11 @@ void Init_native_extension(void) {
1482
1244
  rb_hash_aset(compile_flags, ID2SYM(rb_intern("str_buf_optimization")), Qtrue);
1483
1245
  #endif
1484
1246
 
1485
- rb_define_const(mCataract, "COMPILE_FLAGS", compile_flags);
1247
+ rb_define_const(mNative, "COMPILE_FLAGS", compile_flags);
1486
1248
 
1487
- // Flag to indicate native extension is loaded (for pure Ruby fallback detection)
1488
- rb_define_const(mCataract, "NATIVE_EXTENSION_LOADED", Qtrue);
1249
+ // Flag to indicate the native backend is loaded (for pure Ruby fallback detection)
1250
+ rb_define_const(mNative, "NATIVE_EXTENSION_LOADED", Qtrue);
1489
1251
 
1490
1252
  // Implementation type constant
1491
- rb_define_const(mCataract, "IMPLEMENTATION", ID2SYM(rb_intern("native")));
1253
+ rb_define_const(mNative, "IMPLEMENTATION", ID2SYM(rb_intern("native")));
1492
1254
  }