cataract 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -3
  4. data/.rubocop.yml +1 -0
  5. data/BENCHMARKS.md +24 -6
  6. data/CHANGELOG.md +29 -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 +251 -458
  11. data/ext/cataract/cataract.h +127 -16
  12. data/ext/cataract/css_parser.c +1029 -700
  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 +13 -7
  18. data/lib/cataract/error.rb +49 -0
  19. data/lib/cataract/import_resolver.rb +36 -16
  20. data/lib/cataract/pure/byte_constants.rb +4 -1
  21. data/lib/cataract/pure/flatten.rb +143 -145
  22. data/lib/cataract/pure/parser.rb +623 -388
  23. data/lib/cataract/pure/serializer.rb +109 -104
  24. data/lib/cataract/pure/specificity.rb +112 -156
  25. data/lib/cataract/pure.rb +1 -6
  26. data/lib/cataract/stylesheet.rb +480 -410
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +1 -0
  29. metadata +3 -13
  30. data/ext/cataract/import_scanner.c +0 -174
  31. data/ext/cataract_old/cataract.c +0 -393
  32. data/ext/cataract_old/cataract.h +0 -250
  33. data/ext/cataract_old/css_parser.c +0 -933
  34. data/ext/cataract_old/extconf.rb +0 -67
  35. data/ext/cataract_old/import_scanner.c +0 -174
  36. data/ext/cataract_old/merge.c +0 -776
  37. data/ext/cataract_old/shorthand_expander.c +0 -902
  38. data/ext/cataract_old/specificity.c +0 -213
  39. data/ext/cataract_old/stylesheet.c +0 -290
  40. data/ext/cataract_old/value_splitter.c +0 -116
@@ -14,7 +14,7 @@ VALUE cMediaQuery;
14
14
  VALUE eCataractError;
15
15
  VALUE eDepthError;
16
16
  VALUE eSizeError;
17
- VALUE eParserError;
17
+ VALUE eParseError;
18
18
 
19
19
  // ============================================================================
20
20
  // Helper Functions
@@ -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,23 @@ 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
+ // Rule and AtRule don't share a member layout past their first few fields
381
+ // (see the AT_RULE_* comment in cataract.h), so reading parent_rule_id or
382
+ // media_query_id off a rule of unknown type needs to dispatch on which
383
+ // struct it actually is rather than always using Rule's index - otherwise,
384
+ // for an AtRule, RULE_PARENT_RULE_ID's index (4) actually reads its
385
+ // media_query_id field instead.
386
+ static inline VALUE rule_parent_id(VALUE rule) {
387
+ // AtRule instances are never nested via CSS nesting - only Rule can be.
388
+ return rb_obj_is_kind_of(rule, cAtRule) ? Qnil : rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
389
+ }
390
+
391
+ static inline VALUE rule_media_query_id_of(VALUE rule) {
392
+ return rb_obj_is_kind_of(rule, cAtRule)
393
+ ? rb_struct_aref(rule, INT2FIX(AT_RULE_MEDIA_QUERY_ID))
394
+ : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
395
+ }
396
+
396
397
  // Formatting options for stylesheet serialization
397
398
  // Avoids mode flags and if/else branches - all behavior controlled by struct values
398
399
  struct format_opts {
@@ -404,6 +405,126 @@ struct format_opts {
404
405
  int add_blank_lines; // 0 (compact) vs 1 (formatted)
405
406
  };
406
407
 
408
+ // Serialize a single rule, compact or formatted depending on whether
409
+ // decl_indent is NULL - mirrors the format_opts indent-as-mode-flag
410
+ // convention used throughout this file.
411
+ static inline void emit_rule(VALUE result, VALUE rule, const char *decl_indent, const char *rule_prefix) {
412
+ if (decl_indent) {
413
+ serialize_rule_formatted(result, rule, rule_prefix, 1);
414
+ } else {
415
+ serialize_rule(result, rule);
416
+ }
417
+ }
418
+
419
+ // Scan rule_ids_in_list for other not-yet-processed rules that share
420
+ // rule_media_query_id and rule_declarations with the current rule, marking
421
+ // each match as processed. Returns an array of their selectors (does not
422
+ // include the current rule's own selector or mark it processed - the
423
+ // caller's rule_id is handled by serialize_rule_in_group).
424
+ static VALUE collect_matching_selectors(VALUE rule_ids_in_list, VALUE rule_media_query_id, VALUE rule_declarations, VALUE rules_array, VALUE processed_rule_ids) {
425
+ VALUE matching_selectors = rb_ary_new();
426
+ long list_len = RARRAY_LEN(rule_ids_in_list);
427
+
428
+ for (long j = 0; j < list_len; j++) {
429
+ VALUE other_rule_id = rb_ary_entry(rule_ids_in_list, j);
430
+
431
+ // Skip if already processed
432
+ if (RTEST(rb_hash_aref(processed_rule_ids, other_rule_id))) {
433
+ continue;
434
+ }
435
+
436
+ // Find the rule by ID
437
+ VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
438
+ if (NIL_P(other_rule)) continue;
439
+
440
+ // Check same media context (compare media_query_id directly)
441
+ VALUE other_rule_media_query_id = rb_struct_aref(other_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
442
+ if (!rb_equal(rule_media_query_id, other_rule_media_query_id)) {
443
+ continue;
444
+ }
445
+
446
+ // Check if declarations match
447
+ VALUE other_declarations = rb_struct_aref(other_rule, INT2FIX(RULE_DECLARATIONS));
448
+ if (rb_equal(rule_declarations, other_declarations)) {
449
+ VALUE other_selector = rb_struct_aref(other_rule, INT2FIX(RULE_SELECTOR));
450
+ rb_ary_push(matching_selectors, other_selector);
451
+ rb_hash_aset(processed_rule_ids, other_rule_id, Qtrue);
452
+ }
453
+ }
454
+
455
+ return matching_selectors;
456
+ }
457
+
458
+ // Emit a comma-joined group of matching selectors sharing one declaration
459
+ // block, or fall back to a single rule if grouping didn't find any matches.
460
+ // rule_prefix is written before the (possibly joined) selector line and
461
+ // before the closing brace - "" at the top level, opts->media_indent inside
462
+ // a media block (a no-op when "").
463
+ 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) {
464
+ if (RARRAY_LEN(matching_selectors) > 1) {
465
+ VALUE selector_str = rb_ary_join(matching_selectors, rb_str_new_cstr(", "));
466
+ rb_str_cat2(result, rule_prefix);
467
+ rb_str_append(result, selector_str);
468
+ rb_str_cat2(result, opts->opening_brace);
469
+ serialize_declarations(result, rule_declarations, decl_indent);
470
+ rb_str_cat2(result, rule_prefix);
471
+ rb_str_cat2(result, opts->closing_brace);
472
+ RB_GC_GUARD(selector_str);
473
+ } else {
474
+ emit_rule(result, rule, decl_indent, rule_prefix);
475
+ }
476
+ }
477
+
478
+ // Loop-invariant state shared by every serialize_rule_in_group call within
479
+ // one serialize_stylesheet_with_grouping invocation - populated once instead
480
+ // of threading 5 params through on every call.
481
+ struct rule_group_ctx {
482
+ VALUE selector_lists;
483
+ int grouping_enabled;
484
+ VALUE rules_array;
485
+ VALUE processed_rule_ids;
486
+ const struct format_opts *opts;
487
+ };
488
+
489
+ // Serialize one rule, applying selector-list grouping if enabled. Used
490
+ // identically for both the base (not-in-media) and in-media contexts -
491
+ // in_media selects the relevant format_opts fields ("" and
492
+ // opts->decl_indent_base at the base level; opts->media_indent and
493
+ // opts->decl_indent_media inside a media block).
494
+ static void serialize_rule_in_group(
495
+ VALUE result,
496
+ VALUE rule,
497
+ VALUE rule_id,
498
+ VALUE rule_media_query_id,
499
+ const struct rule_group_ctx *ctx,
500
+ int in_media
501
+ ) {
502
+ const char *decl_indent = in_media ? ctx->opts->decl_indent_media : ctx->opts->decl_indent_base;
503
+ const char *rule_prefix = in_media ? ctx->opts->media_indent : "";
504
+
505
+ // Try to group with other rules from same selector list
506
+ // Check if this is a Rule (not AtRule) before accessing selector_list_id
507
+ if (ctx->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
+ // Get list of rule IDs in this selector list
511
+ VALUE rule_ids_in_list = rb_hash_aref(ctx->selector_lists, selector_list_id);
512
+
513
+ if (!NIL_P(rule_ids_in_list) && RARRAY_LEN(rule_ids_in_list) > 1) {
514
+ VALUE rule_declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
515
+ VALUE matching_selectors = collect_matching_selectors(rule_ids_in_list, rule_media_query_id, rule_declarations, ctx->rules_array, ctx->processed_rule_ids);
516
+ emit_grouped_rule(result, rule, matching_selectors, rule_declarations, ctx->opts, decl_indent, rule_prefix);
517
+ return;
518
+ }
519
+ }
520
+ }
521
+
522
+ // No grouping applies (disabled, not a Rule, no selector_list_id, or a
523
+ // selector list of 1) - serialize normally
524
+ emit_rule(result, rule, decl_indent, rule_prefix);
525
+ rb_hash_aset(ctx->processed_rule_ids, rule_id, Qtrue);
526
+ }
527
+
407
528
  // Private shared implementation for stylesheet serialization with optional selector list grouping
408
529
  // All formatting behavior controlled by format_opts struct to avoid mode flags and if/else branches
409
530
  static VALUE serialize_stylesheet_with_grouping(
@@ -430,6 +551,8 @@ static VALUE serialize_stylesheet_with_grouping(
430
551
  // Track processed rules to avoid duplicates when grouping
431
552
  VALUE processed_rule_ids = rb_hash_new();
432
553
 
554
+ struct rule_group_ctx group_ctx = { selector_lists, grouping_enabled, rules_array, processed_rule_ids, opts };
555
+
433
556
  // Iterate through rules in insertion order, grouping consecutive media queries
434
557
  VALUE current_media = Qnil;
435
558
  int in_media_block = 0;
@@ -443,8 +566,8 @@ static VALUE serialize_stylesheet_with_grouping(
443
566
  continue;
444
567
  }
445
568
 
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));
569
+ // Get media_query_id and fetch MediaQuery object (nil for rules without media query)
570
+ VALUE rule_media_query_id = rule_media_query_id_of(rule);
448
571
  VALUE rule_media = Qnil;
449
572
  if (!NIL_P(rule_media_query_id)) {
450
573
  rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
@@ -464,95 +587,7 @@ static VALUE serialize_stylesheet_with_grouping(
464
587
  rb_str_cat2(result, "\n");
465
588
  }
466
589
 
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
- }
590
+ serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 0);
556
591
  } else {
557
592
  // This rule is in a media query
558
593
  // Check if media query changed from previous rule (compare MediaQuery objects by value)
@@ -580,80 +615,7 @@ static VALUE serialize_stylesheet_with_grouping(
580
615
  in_media_block = 1;
581
616
  }
582
617
 
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
- }
618
+ serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 1);
657
619
  }
658
620
  }
659
621
 
@@ -668,33 +630,6 @@ static VALUE serialize_stylesheet_with_grouping(
668
630
  return result;
669
631
  }
670
632
 
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
633
  // Forward declarations
699
634
  static void serialize_children_only(VALUE result, VALUE rules_array, long rule_idx,
700
635
  VALUE parent_to_children, VALUE parent_selector,
@@ -766,7 +701,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
766
701
  rb_str_cat2(child_indent, " ");
767
702
  }
768
703
  const char *child_indent_ptr = RSTRING_PTR(child_indent);
769
- serialize_declarations_formatted(result, child_declarations, child_indent_ptr);
704
+ serialize_declarations(result, child_declarations, child_indent_ptr);
770
705
  RB_GC_GUARD(child_indent);
771
706
  }
772
707
 
@@ -787,7 +722,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
787
722
 
788
723
  // Serialize child declarations
789
724
  VALUE child_declarations = rb_struct_aref(child, INT2FIX(RULE_DECLARATIONS));
790
- serialize_declarations(result, child_declarations);
725
+ serialize_declarations(result, child_declarations, NULL);
791
726
 
792
727
  // Recursively serialize grandchildren
793
728
  serialize_children_only(result, rules_array, child_idx, parent_to_children,
@@ -823,7 +758,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
823
758
  rb_str_cat2(child_indent, " ");
824
759
  }
825
760
  const char *child_indent_ptr = RSTRING_PTR(child_indent);
826
- serialize_declarations_formatted(result, child_declarations, child_indent_ptr);
761
+ serialize_declarations(result, child_declarations, child_indent_ptr);
827
762
  RB_GC_GUARD(child_indent);
828
763
  }
829
764
 
@@ -835,7 +770,7 @@ static void serialize_children_only(VALUE result, VALUE rules_array, long rule_i
835
770
  rb_str_cat2(result, " { ");
836
771
 
837
772
  VALUE child_declarations = rb_struct_aref(child, INT2FIX(RULE_DECLARATIONS));
838
- serialize_declarations(result, child_declarations);
773
+ serialize_declarations(result, child_declarations, NULL);
839
774
 
840
775
  rb_str_cat2(result, " }");
841
776
  }
@@ -876,7 +811,7 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
876
811
  if (!NIL_P(declarations) && RARRAY_LEN(declarations) > 0) {
877
812
  DEBUG_PRINTF("[SERIALIZE_RULE] Serializing %ld declarations with indent='%s' (%d spaces)\n",
878
813
  RARRAY_LEN(declarations), decl_indent, decl_spaces);
879
- serialize_declarations_formatted(result, declarations, decl_indent);
814
+ serialize_declarations(result, declarations, decl_indent);
880
815
  }
881
816
 
882
817
  // Serialize nested children
@@ -891,7 +826,7 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
891
826
  rb_str_cat2(result, " { ");
892
827
 
893
828
  // Serialize own declarations
894
- serialize_declarations(result, declarations);
829
+ serialize_declarations(result, declarations, NULL);
895
830
 
896
831
  // Serialize nested children
897
832
  serialize_children_only(result, rules_array, rule_idx, parent_to_children,
@@ -904,50 +839,25 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
904
839
  RB_GC_GUARD(rule);
905
840
  }
906
841
 
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
842
+ // Shared implementation for stylesheet serialization with nesting support.
843
+ // Handles both compact (to_s) and formatted (to_formatted_s) output via the
844
+ // format_opts struct, mirroring the pattern serialize_stylesheet_with_grouping
845
+ // uses for the no-nesting fast path.
846
+ static VALUE serialize_stylesheet_with_nesting(
847
+ VALUE rules_array,
848
+ VALUE media_queries,
849
+ VALUE result,
850
+ const struct format_opts *opts
851
+ ) {
852
+ int formatted = (opts->decl_indent_base != NULL);
935
853
  long total_rules = RARRAY_LEN(rules_array);
936
- VALUE result = rb_str_new_cstr("");
937
-
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
- }
944
854
 
945
855
  // Build parent_to_children map (parent_rule_id -> array of child indices)
946
856
  // This allows O(1) lookup of children when serializing each parent
947
857
  VALUE parent_to_children = rb_hash_new();
948
858
  for (long i = 0; i < total_rules; i++) {
949
859
  VALUE rule = rb_ary_entry(rules_array, i);
950
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
860
+ VALUE parent_id = rule_parent_id(rule);
951
861
 
952
862
  if (!NIL_P(parent_id)) {
953
863
  DEBUG_PRINTF("[MAP] Rule %ld has parent_id=%s, adding to map\n", i,
@@ -973,7 +883,7 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
973
883
  DEBUG_PRINTF("[SERIALIZE] Starting serialization, total_rules=%ld\n", total_rules);
974
884
  for (long i = 0; i < total_rules; i++) {
975
885
  VALUE rule = rb_ary_entry(rules_array, i);
976
- VALUE parent_id = rb_struct_aref(rule, INT2FIX(RULE_PARENT_RULE_ID));
886
+ VALUE parent_id = rule_parent_id(rule);
977
887
 
978
888
  DEBUG_PRINTF("[SERIALIZE] Rule %ld: selector=%s, parent_id=%s\n", i,
979
889
  RSTRING_PTR(rb_struct_aref(rule, INT2FIX(RULE_SELECTOR))),
@@ -986,7 +896,7 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
986
896
  }
987
897
 
988
898
  // 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));
899
+ VALUE rule_media_query_id = rule_media_query_id_of(rule);
990
900
  VALUE rule_media = Qnil;
991
901
  if (!NIL_P(rule_media_query_id)) {
992
902
  rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
@@ -1002,6 +912,10 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1002
912
  rb_str_cat2(result, "}\n");
1003
913
  in_media_block = 0;
1004
914
  current_media = Qnil;
915
+
916
+ if (opts->add_blank_lines) {
917
+ rb_str_cat2(result, "\n");
918
+ }
1005
919
  }
1006
920
  } else {
1007
921
  // In media - check if we need to open/change block (compare MediaQuery objects by value)
@@ -1009,6 +923,9 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1009
923
  // Close previous media block if open
1010
924
  if (in_media_block) {
1011
925
  rb_str_cat2(result, "}\n");
926
+ } else if (opts->add_blank_lines && RSTRING_LEN(result) > 0) {
927
+ // Add blank line before new media block (except at start)
928
+ rb_str_cat2(result, "\n");
1012
929
  }
1013
930
  // Open new media block - store the MediaQuery object for comparison
1014
931
  current_media = rule_media;
@@ -1025,11 +942,18 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1025
942
  continue;
1026
943
  }
1027
944
 
945
+ // Add indent if inside media block (no-op in compact mode, where media_indent is "")
946
+ if (in_media_block) {
947
+ DEBUG_PRINTF("[FORMATTED] Adding base indent for media block\n");
948
+ rb_str_cat2(result, opts->media_indent);
949
+ }
950
+
1028
951
  // Serialize rule with nested children
952
+ DEBUG_PRINTF("[FORMATTED] Calling serialize_rule_with_children, in_media_block=%d\n", in_media_block);
1029
953
  serialize_rule_with_children(
1030
954
  result, rules_array, i, parent_to_children, media_queries,
1031
- 0, // formatted (compact)
1032
- 0 // indent_level (top-level)
955
+ formatted,
956
+ in_media_block ? 1 : 0 // indent_level (unused when !formatted)
1033
957
  );
1034
958
  }
1035
959
 
@@ -1042,47 +966,16 @@ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE
1042
966
  return result;
1043
967
  }
1044
968
 
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) {
969
+ // Shared implementation for stylesheet_to_s/stylesheet_to_formatted_s - both wrap
970
+ // this, differing only in which format_opts they select. Dispatches on has_nesting
971
+ // to pick the selector-list-grouping serializer (no nesting - zero overhead) or the
972
+ // parent/child lookahead serializer (has nesting).
973
+ 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
974
  Check_Type(rules_array, T_ARRAY);
1075
975
  Check_Type(media_queries, T_ARRAY);
1076
976
  if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
1077
977
  if (!NIL_P(selector_lists)) Check_Type(selector_lists, T_HASH);
1078
978
 
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
979
  VALUE result = rb_str_new_cstr("");
1087
980
 
1088
981
  // Add charset if present
@@ -1092,101 +985,45 @@ static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE char
1092
985
  rb_str_cat2(result, "\";\n");
1093
986
  }
1094
987
 
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
- }
988
+ struct format_opts opts;
989
+ if (formatted) {
990
+ opts = (struct format_opts){
991
+ .opening_brace = " {\n",
992
+ .closing_brace = "}\n",
993
+ .media_indent = " ",
994
+ .decl_indent_base = " ",
995
+ .decl_indent_media = " ",
996
+ .add_blank_lines = 1
997
+ };
998
+ } else {
999
+ opts = (struct format_opts){
1000
+ .opening_brace = " { ",
1001
+ .closing_brace = " }\n",
1002
+ .media_indent = "",
1003
+ .decl_indent_base = NULL,
1004
+ .decl_indent_media = NULL,
1005
+ .add_blank_lines = 0
1006
+ };
1109
1007
  }
1110
1008
 
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
- );
1009
+ // Fast path: if no nesting, use the selector-list-grouping serializer (zero overhead)
1010
+ if (!RTEST(has_nesting)) {
1011
+ return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, &opts);
1181
1012
  }
1182
1013
 
1183
- // Close final media block if still open
1184
- if (in_media_block) {
1185
- rb_str_cat2(result, "}\n");
1186
- }
1014
+ // Has nesting - use the parent/child lookahead serializer
1015
+ // TODO: Phase 2 - use selector_lists for grouping on the nesting path too
1016
+ return serialize_stylesheet_with_nesting(rules_array, media_queries, result, &opts);
1017
+ }
1187
1018
 
1188
- RB_GC_GUARD(parent_to_children);
1189
- return result;
1019
+ // New stylesheet serialization entry point - checks for nesting and delegates
1020
+ 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) {
1021
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 0);
1022
+ }
1023
+
1024
+ // Formatted version with indentation and newlines (with nesting support)
1025
+ 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) {
1026
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 1);
1190
1027
  }
1191
1028
 
1192
1029
  /*
@@ -1207,62 +1044,18 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1207
1044
  while (pos < end && (IS_WHITESPACE(*pos) || *pos == ';')) pos++;
1208
1045
  if (pos >= end) break;
1209
1046
 
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
- }
1047
+ // Standalone declaration-list input never contains braces, so only
1048
+ // ':' terminates the property scan (stop_prop_scan_early=0).
1049
+ struct declaration_span span;
1050
+ if (!parse_one_declaration(&pos, end, 0, &span)) break; // No colon found
1258
1051
 
1259
1052
  // 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;
1053
+ if (span.val_end > span.val_start) {
1054
+ long prop_len = span.prop_end - span.prop_start;
1055
+ long val_len = span.val_end - span.val_start;
1263
1056
 
1264
1057
  // Create property string (US-ASCII, lowercased)
1265
- VALUE property = rb_usascii_str_new(prop_start, prop_len);
1058
+ VALUE property = rb_usascii_str_new(span.prop_start, prop_len);
1266
1059
  // Lowercase it inline
1267
1060
  char *prop_ptr = RSTRING_PTR(property);
1268
1061
  for (long i = 0; i < prop_len; i++) {
@@ -1271,11 +1064,11 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1271
1064
  }
1272
1065
  }
1273
1066
 
1274
- VALUE value = rb_utf8_str_new(val_start, val_len);
1067
+ VALUE value = rb_utf8_str_new(span.val_start, val_len);
1275
1068
 
1276
1069
  // Create Declaration struct
1277
1070
  VALUE decl = rb_struct_new(cDeclaration,
1278
- property, value, is_important ? Qtrue : Qfalse);
1071
+ property, value, span.is_important ? Qtrue : Qfalse);
1279
1072
 
1280
1073
  rb_ary_push(declarations, decl);
1281
1074
  }
@@ -1408,10 +1201,10 @@ void Init_native_extension(void) {
1408
1201
  eSizeError = rb_define_class_under(mCataract, "SizeError", eCataractError);
1409
1202
  }
1410
1203
 
1411
- if (rb_const_defined(mCataract, rb_intern("ParserError"))) {
1412
- eParserError = rb_const_get(mCataract, rb_intern("ParserError"));
1204
+ if (rb_const_defined(mCataract, rb_intern("ParseError"))) {
1205
+ eParseError = rb_const_get(mCataract, rb_intern("ParseError"));
1413
1206
  } else {
1414
- eParserError = rb_define_class_under(mCataract, "ParserError", eCataractError);
1207
+ eParseError = rb_define_class_under(mCataract, "ParseError", eCataractError);
1415
1208
  }
1416
1209
 
1417
1210
  // Reuse Ruby-defined structs (they must be defined before loading this extension)