cataract 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a6424d26e77e0dd4e0167324dc6190f03a9ca041e51fa9e61a78163f6fe312b
4
- data.tar.gz: 7c538f41ebd8e93a53caed4a5f066eea076277c26174bff7d22837730f03d90d
3
+ metadata.gz: 67046f0d371d7b0b077971e8634ff4054e64fe23a55c80b1a9f80e3dee6d804b
4
+ data.tar.gz: 776865b7490e22c326c0bab93a3da32f088177817b3e237c68b66e233df39021
5
5
  SHA512:
6
- metadata.gz: d2297f4139e8025ac39b9467eaa4bd0541683af7e4870813159c557df813f411d389b59f5b28adb9c46f5390aba73405b937e946d9097b943e0ba70f6cb37963
7
- data.tar.gz: 0b4d9b6ec564fd674ad8558b7b4f309494c0ca12628947a8501b17ccb3567e1a2667dd0ea89f88ad82f688ac580122ec7e9505b8149433504ef40d923228b75b
6
+ metadata.gz: 21f5ccd9b4ed3e0831fa8723d02493b7b4a3582c359a3e3bae927e554d207422a0139592b23889d273ca1ed3a1888679c729d9ad0f4aa2bf4c90d18b2e16fe0c
7
+ data.tar.gz: f149256218337a47a1b27cd945857dd358199ceb47f70274c0c7db5cb3fa196d84a55281528c0f22d12da4bbbaff3b92c6e315210906a75aee2d304a9b91c275
data/.gitignore CHANGED
@@ -4,9 +4,6 @@ tmp/
4
4
  *.so
5
5
  *.o
6
6
 
7
- # Generated C code from Ragel
8
- ext/**/cataract.c
9
-
10
7
  # Compiled extensions
11
8
  lib/**/*.so
12
9
  lib/**/*.bundle
data/.rubocop_todo.yml CHANGED
@@ -22,21 +22,13 @@ Layout/EmptyLinesAfterModuleInclusion:
22
22
  Exclude:
23
23
  - 'benchmarks/parsing_tests.rb'
24
24
 
25
- # Offense count: 1
26
- # This cop supports safe autocorrection (--autocorrect).
27
- # Configuration parameters: EnforcedStyle, IndentationWidth.
28
- # SupportedStyles: aligned, indented
29
- Layout/MultilineOperationIndentation:
30
- Exclude:
31
- - 'lib/cataract/pure/helpers.rb'
32
-
33
- # Offense count: 1
25
+ # Offense count: 2
34
26
  # This cop supports safe autocorrection (--autocorrect).
35
27
  Lint/AmbiguousOperatorPrecedence:
36
28
  Exclude:
37
29
  - 'lib/cataract/pure/flatten.rb'
38
30
 
39
- # Offense count: 2
31
+ # Offense count: 4
40
32
  # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
41
33
  Lint/DuplicateBranch:
42
34
  Exclude:
@@ -69,7 +61,7 @@ Minitest/EmptyLineBeforeAssertionMethods:
69
61
  Exclude:
70
62
  - 'test/test_stylesheet.rb'
71
63
 
72
- # Offense count: 7
64
+ # Offense count: 14
73
65
  # This cop supports unsafe autocorrection (--autocorrect-all).
74
66
  Performance/UnfreezeString:
75
67
  Exclude:
@@ -77,7 +69,7 @@ Performance/UnfreezeString:
77
69
  - 'lib/cataract/pure/flatten.rb'
78
70
  - 'lib/cataract/pure/parser.rb'
79
71
 
80
- # Offense count: 4
72
+ # Offense count: 8
81
73
  # This cop supports safe autocorrection (--autocorrect).
82
74
  # Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions.
83
75
  # SupportedStyles: assign_to_condition, assign_inside_condition
@@ -86,7 +78,7 @@ Style/ConditionalAssignment:
86
78
  - 'lib/cataract/pure/imports.rb'
87
79
  - 'lib/cataract/pure/flatten.rb'
88
80
 
89
- # Offense count: 4
81
+ # Offense count: 8
90
82
  # Configuration parameters: AllowedConstants.
91
83
  Style/Documentation:
92
84
  Exclude:
@@ -107,7 +99,7 @@ Style/HashLikeCase:
107
99
  - 'benchmarks/serialization_tests.rb'
108
100
  - 'benchmarks/specificity_tests.rb'
109
101
 
110
- # Offense count: 14
102
+ # Offense count: 28
111
103
  # This cop supports unsafe autocorrection (--autocorrect-all).
112
104
  Style/IdenticalConditionalBranches:
113
105
  Exclude:
@@ -118,7 +110,7 @@ Style/IdenticalConditionalBranches:
118
110
  - 'lib/cataract/pure/parser.rb'
119
111
  - 'lib/cataract/pure/serializer.rb'
120
112
 
121
- # Offense count: 5
113
+ # Offense count: 10
122
114
  # This cop supports safe autocorrection (--autocorrect).
123
115
  # Configuration parameters: AllowIfModifier.
124
116
  Style/IfInsideElse:
@@ -127,19 +119,19 @@ Style/IfInsideElse:
127
119
  - 'lib/cataract/pure/serializer.rb'
128
120
  - 'lib/cataract/pure/specificity.rb'
129
121
 
130
- # Offense count: 1
122
+ # Offense count: 2
131
123
  # This cop supports safe autocorrection (--autocorrect).
132
124
  Style/NegatedIfElseCondition:
133
125
  Exclude:
134
126
  - 'lib/cataract/pure/flatten.rb'
135
127
 
136
- # Offense count: 1
128
+ # Offense count: 2
137
129
  # This cop supports safe autocorrection (--autocorrect).
138
130
  Style/RedundantAssignment:
139
131
  Exclude:
140
132
  - 'lib/cataract/pure/specificity.rb'
141
133
 
142
- # Offense count: 2
134
+ # Offense count: 4
143
135
  # This cop supports safe autocorrection (--autocorrect).
144
136
  Style/RedundantParentheses:
145
137
  Exclude:
@@ -152,7 +144,7 @@ Style/RedundantRegexpArgument:
152
144
  Exclude:
153
145
  - 'test/test_stylesheet.rb'
154
146
 
155
- # Offense count: 3
147
+ # Offense count: 6
156
148
  # This cop supports safe autocorrection (--autocorrect).
157
149
  # Configuration parameters: AllowModifier.
158
150
  Style/SoleNestedConditional:
data/BENCHMARKS.md CHANGED
@@ -8,11 +8,11 @@ Performance comparison between Cataract's C extension and pure Ruby implementati
8
8
 
9
9
  ## Test Environment
10
10
 
11
- - **Ruby**: ruby 3.4.7 (2025-10-08 revision 7a5688e2a2) +YJIT +PRISM [arm64-darwin23]
11
+ - **Ruby**: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin25]
12
12
  - **CPU**: Apple M1 Pro
13
13
  - **Memory**: 32GB
14
- - **OS**: macOS 14.5
15
- - **Generated**: 2025-11-11T16:02:08-06:00
14
+ - **OS**: macOS 26.3.1
15
+ - **Generated**: 2026-07-08T10:35:16-05:00
16
16
 
17
17
  ## CSS Parsing
18
18
 
@@ -20,17 +20,17 @@ Time to parse CSS into internal data structures
20
20
 
21
21
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) |
22
22
  |-----------|--------|----------------|-------------|
23
- | Small CSS (64 lines, 1.0KB) | 37.27K i/s | 3.32K i/s | 13.48K i/s |
24
- | Medium CSS with @media (139 lines, 1.6KB) | 34.66K i/s | 2.06K i/s | 8.94K i/s |
25
- | Selector lists (3500 lines, 62.5KB, 500 lists) | 485.7 i/s | 55.7 i/s | 213.6 i/s |
23
+ | Small CSS (64 lines, 1.0KB) | 36.76K i/s | 3.33K i/s | 13.66K i/s |
24
+ | Medium CSS with @media (139 lines, 1.6KB) | 29.98K i/s | 2.11K i/s | 8.98K i/s |
25
+ | Selector lists (3500 lines, 62.5KB, 500 lists) | 447.4 i/s | 58.0 i/s | 219.8 i/s |
26
26
 
27
27
  ### Speedups
28
28
 
29
29
  | Comparison | Speedup |
30
30
  |------------|---------|
31
- | Native vs Pure (no YJIT) | 14.4x faster (avg) |
32
- | Native vs Pure (YJIT) | 3.38x faster (avg) |
33
- | YJIT impact on Pure Ruby | 4.23x faster (avg) |
31
+ | Native vs Pure (no YJIT) | 13.03x faster (avg) |
32
+ | Native vs Pure (YJIT) | 3.12x faster (avg) |
33
+ | YJIT impact on Pure Ruby | 4.15x faster (avg) |
34
34
 
35
35
  ### Parse Error Checking Overhead
36
36
 
@@ -38,17 +38,17 @@ Parse error detection can be enabled with `raise_parse_errors: true`. This compa
38
38
 
39
39
  | Configuration | Native | Pure (no YJIT) | Pure (YJIT) |
40
40
  |---------------|--------|----------------|-------------|
41
- | Medium CSS (139 lines) - no error checking | 35.18K i/s | 2.07K i/s | 9.05K i/s |
42
- | Medium CSS (139 lines) - with error checking | 34.84K i/s | 1.9K i/s | 8.19K i/s |
41
+ | Medium CSS (139 lines) - no error checking | 33.57K i/s | 2.12K i/s | 9.08K i/s |
42
+ | Medium CSS (139 lines) - with error checking | 33.09K i/s | 1.94K i/s | 8.19K i/s |
43
43
 
44
44
  **Overhead Analysis:**
45
45
 
46
46
 
47
47
  | Implementation | Overhead |
48
48
  |----------------|----------|
49
- | Native | ~0% (within noise) |
50
- | Pure (no YJIT) | 8.9% slower |
51
- | Pure (YJIT) | 10.4% slower |
49
+ | Native | 1.5% slower |
50
+ | Pure (no YJIT) | 9.2% slower |
51
+ | Pure (YJIT) | 10.9% slower |
52
52
 
53
53
  ---
54
54
 
@@ -58,19 +58,19 @@ Time to convert parsed CSS back to string format
58
58
 
59
59
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) |
60
60
  |-----------|--------|----------------|-------------|
61
- | to_s (Bootstrap - 191KB) | 1.32K i/s | 360.2 i/s | 656.6 i/s |
62
- | to_s (Compact utilities - 2.9KB) | 1.32K i/s | 360.2 i/s | 656.6 i/s |
63
- | to_formatted_s (Nested CSS - 1.3KB) | 176.13K i/s | 60.97K i/s | 121.5K i/s |
64
- | to_s with selector_lists (3.4KB) | 19.03K i/s | 8.01K i/s | 14.2K i/s |
65
- | Media filtering (Bootstrap print only) | 283.24K i/s | 97.28K i/s | 161.56K i/s |
61
+ | to_s (Bootstrap - 191KB) | 1.05K i/s | 368.6 i/s | 555.6 i/s |
62
+ | to_s (Compact utilities - 2.9KB) | 1.05K i/s | 368.6 i/s | 555.6 i/s |
63
+ | to_formatted_s (Nested CSS - 1.3KB) | 156.05K i/s | 53.98K i/s | 90.18K i/s |
64
+ | to_s with selector_lists (3.4KB) | 15.37K i/s | 7.71K i/s | 12.09K i/s |
65
+ | Media filtering (Bootstrap print only) | 1.18K i/s | 547.1 i/s | 769.2 i/s |
66
66
 
67
67
  ### Speedups
68
68
 
69
69
  | Comparison | Speedup |
70
70
  |------------|---------|
71
- | Native vs Pure (no YJIT) | 3.24x faster (avg) |
72
- | Native vs Pure (YJIT) | 1.73x faster (avg) |
73
- | YJIT impact on Pure Ruby | 1.86x faster (avg) |
71
+ | Native vs Pure (no YJIT) | 2.71x faster (avg) |
72
+ | Native vs Pure (YJIT) | 1.75x faster (avg) |
73
+ | YJIT impact on Pure Ruby | 1.55x faster (avg) |
74
74
 
75
75
  ---
76
76
 
@@ -80,20 +80,20 @@ Time to calculate CSS selector specificity values
80
80
 
81
81
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) |
82
82
  |-----------|--------|----------------|-------------|
83
- | Simple Selectors | 7.28M i/s | 506.61K i/s | 2.45M i/s |
84
- | Compound Selectors | 6.4M i/s | 186.62K i/s | 405.15K i/s |
85
- | Combinators | 5.04M i/s | 147.07K i/s | 255.92K i/s |
86
- | Pseudo-classes & Pseudo-elements | 5.17M i/s | 117.16K i/s | 195.12K i/s |
87
- | :not() Pseudo-class (CSS3) | 3.48M i/s | 103.47K i/s | 161.34K i/s |
88
- | Complex Real-world Selectors | 4.01M i/s | 52.41K i/s | 77.48K i/s |
83
+ | Simple Selectors | 1.97M i/s | 419.98K i/s | 1.44M i/s |
84
+ | Compound Selectors | 1.89M i/s | 186.59K i/s | 523.08K i/s |
85
+ | Combinators | 1.43M i/s | 142.61K i/s | 285.41K i/s |
86
+ | Pseudo-classes & Pseudo-elements | 1.41M i/s | 114.7K i/s | 235.07K i/s |
87
+ | :not() Pseudo-class (CSS3) | 1.52M i/s | 110.64K i/s | 247.84K i/s |
88
+ | Complex Real-world Selectors | 1.62M i/s | 56.2K i/s | 126.65K i/s |
89
89
 
90
90
  ### Speedups
91
91
 
92
92
  | Comparison | Speedup |
93
93
  |------------|---------|
94
- | Native vs Pure (no YJIT) | 39.54x faster (avg) |
95
- | Native vs Pure (YJIT) | 23.05x faster (avg) |
96
- | YJIT impact on Pure Ruby | 2.24x faster (avg) |
94
+ | Native vs Pure (no YJIT) | 13.28x faster (avg) |
95
+ | Native vs Pure (YJIT) | 5.82x faster (avg) |
96
+ | YJIT impact on Pure Ruby | 2.46x faster (avg) |
97
97
 
98
98
  ---
99
99
 
@@ -103,20 +103,20 @@ Time to flatten multiple CSS rule sets with same selector
103
103
 
104
104
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) |
105
105
  |-----------|--------|----------------|-------------|
106
- | No shorthand properties (large) | 13.91K i/s | 3.23K i/s | 6.39K i/s |
107
- | Simple properties | 144.19K i/s | 71.11K i/s | 101.38K i/s |
108
- | Cascade with specificity | 172.96K i/s | 69.16K i/s | 99.23K i/s |
109
- | Important declarations | 173.35K i/s | 69.12K i/s | 100.16K i/s |
110
- | Shorthand expansion | 13.91K i/s | 3.23K i/s | 6.39K i/s |
111
- | Complex flattening | 29.86K i/s | 15.77K i/s | 22.09K i/s |
106
+ | No shorthand properties (large) | 12.75K i/s | 3.69K i/s | 6.41K i/s |
107
+ | Simple properties | 142.64K i/s | 69.09K i/s | 100.71K i/s |
108
+ | Cascade with specificity | 178.29K i/s | 67.87K i/s | 101.77K i/s |
109
+ | Important declarations | 177.88K i/s | 68.12K i/s | 101.75K i/s |
110
+ | Shorthand expansion | 12.75K i/s | 3.69K i/s | 6.41K i/s |
111
+ | Complex flattening | 31.73K i/s | 16.04K i/s | 23.28K i/s |
112
112
 
113
113
  ### Speedups
114
114
 
115
115
  | Comparison | Speedup |
116
116
  |------------|---------|
117
- | Native vs Pure (no YJIT) | 2.54x faster (avg) |
118
- | Native vs Pure (YJIT) | 1.63x faster (avg) |
119
- | YJIT impact on Pure Ruby | 1.53x faster (avg) |
117
+ | Native vs Pure (no YJIT) | 2.45x faster (avg) |
118
+ | Native vs Pure (YJIT) | 1.6x faster (avg) |
119
+ | YJIT impact on Pure Ruby | 1.52x faster (avg) |
120
120
 
121
121
  ---
122
122
 
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [0.4.0] - 2026-07-08
2
+
3
+ - Fix: `Declarations.new(some_string)` (standalone CSS declaration-block parsing) now works under the pure Ruby backend (`CATARACT_PURE=1`) - previously raised `NoMethodError`.
4
+ - Fix: a compound `@media` list (e.g. `@media screen, print { ... }`) no longer silently collapses to just its first member when the document also contains CSS nesting elsewhere.
5
+ - Fix: `to_s`/`to_formatted_s` with a specific `media:` filter (anything but `:all`) could crash while grouping comma-separated selector lists (e.g. `h1, h2 { ... }`), if an unrelated rule elsewhere in the document was excluded by the filter. Present in both backends since at least 0.3.0.
6
+ - Fix: `Stylesheet#flatten` (non-destructive) could silently expand shorthand properties (e.g. `margin: 0`) on the *original* stylesheet as a side effect, under the pure Ruby backend. Present since at least 0.3.0; the C extension was unaffected.
7
+ - Breaking (minor): removed `Cataract.merge` (a long-deprecated alias for `.flatten`) and `Cataract.parse_media_types` (undocumented, no real callers) from the C extension's public surface. Use `Cataract.flatten`/`Stylesheet#flatten` instead of `.merge`.
8
+ - Internal: `Cataract`'s own directly-callable methods are now just `.flatten` and `.parse_css`. Previously `Cataract` also exposed `calculate_specificity`, `expand_shorthand`, `parse_declarations`, `stylesheet_to_s`, `stylesheet_to_formatted_s`, and `_parse_css` directly - undocumented implementation leakage, not intended for direct use. The documented ways to reach this functionality (`Rule#specificity`, `Rule#expanded_declarations`, `Declarations.new(some_string)`, `Stylesheet#to_s`/`#to_formatted_s`) are unchanged. The native C extension and pure Ruby backend also now live under `Cataract::Backends::Native` / `Cataract::Backends::Pure`, resolved once per process and held per-`Stylesheet` - this lets both backends coexist in the same process (e.g. for direct comparison). Existing callers relying only on the documented public API should notice nothing.
9
+
1
10
  ## [0.3.0] - 2026-07-06
2
11
 
3
12
  - Security/Breaking: `Stylesheet#load_uri` and `#load_file` now reject `file://` paths under `/etc/`, `/proc/`, `/sys/`, and `/dev/` by default (override with `dangerous_path_prefixes: []`). Previously these methods used a separate, unvalidated fetch implementation; they now reuse `ImportResolver` (the same validation and fetching `@import` resolution already uses), which also fixes `load_uri` not following HTTP redirects.
@@ -377,6 +377,37 @@ static int build_mq_reverse_map_callback(VALUE list_id, VALUE mq_ids, VALUE arg)
377
377
  return ST_CONTINUE;
378
378
  }
379
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
+
380
411
  // Rule and AtRule don't share a member layout past their first few fields
381
412
  // (see the AT_RULE_* comment in cataract.h), so reading parent_rule_id or
382
413
  // media_query_id off a rule of unknown type needs to dispatch on which
@@ -421,7 +452,7 @@ static inline void emit_rule(VALUE result, VALUE rule, const char *decl_indent,
421
452
  // each match as processed. Returns an array of their selectors (does not
422
453
  // include the current rule's own selector or mark it processed - the
423
454
  // 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) {
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) {
425
456
  VALUE matching_selectors = rb_ary_new();
426
457
  long list_len = RARRAY_LEN(rule_ids_in_list);
427
458
 
@@ -434,7 +465,7 @@ static VALUE collect_matching_selectors(VALUE rule_ids_in_list, VALUE rule_media
434
465
  }
435
466
 
436
467
  // Find the rule by ID
437
- VALUE other_rule = rb_ary_entry(rules_array, FIX2INT(other_rule_id));
468
+ VALUE other_rule = rb_hash_aref(rules_by_id, other_rule_id);
438
469
  if (NIL_P(other_rule)) continue;
439
470
 
440
471
  // Check same media context (compare media_query_id directly)
@@ -481,7 +512,7 @@ static void emit_grouped_rule(VALUE result, VALUE rule, VALUE matching_selectors
481
512
  struct rule_group_ctx {
482
513
  VALUE selector_lists;
483
514
  int grouping_enabled;
484
- VALUE rules_array;
515
+ VALUE rules_by_id;
485
516
  VALUE processed_rule_ids;
486
517
  const struct format_opts *opts;
487
518
  };
@@ -512,7 +543,7 @@ static void serialize_rule_in_group(
512
543
 
513
544
  if (!NIL_P(rule_ids_in_list) && RARRAY_LEN(rule_ids_in_list) > 1) {
514
545
  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);
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);
516
547
  emit_grouped_rule(result, rule, matching_selectors, rule_declarations, ctx->opts, decl_indent, rule_prefix);
517
548
  return;
518
549
  }
@@ -542,16 +573,18 @@ static VALUE serialize_stylesheet_with_grouping(
542
573
 
543
574
  // Build reverse map: media_query_id => list_id
544
575
  // This allows us to detect when multiple rules share a comma-separated media query list
545
- VALUE mq_id_to_list_id = rb_hash_new();
546
- if (!NIL_P(media_query_lists) && TYPE(media_query_lists) == T_HASH) {
547
- struct build_mq_reverse_map_ctx ctx = { mq_id_to_list_id };
548
- rb_hash_foreach(media_query_lists, build_mq_reverse_map_callback, (VALUE)&ctx);
549
- }
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;
550
583
 
551
584
  // Track processed rules to avoid duplicates when grouping
552
585
  VALUE processed_rule_ids = rb_hash_new();
553
586
 
554
- struct rule_group_ctx group_ctx = { selector_lists, grouping_enabled, rules_array, processed_rule_ids, opts };
587
+ struct rule_group_ctx group_ctx = { selector_lists, grouping_enabled, rules_by_id, processed_rule_ids, opts };
555
588
 
556
589
  // Iterate through rules in insertion order, grouping consecutive media queries
557
590
  VALUE current_media = Qnil;
@@ -626,6 +659,7 @@ static VALUE serialize_stylesheet_with_grouping(
626
659
 
627
660
  // Guard hash objects we created and used throughout
628
661
  RB_GC_GUARD(mq_id_to_list_id);
662
+ RB_GC_GUARD(rules_by_id);
629
663
  RB_GC_GUARD(processed_rule_ids);
630
664
  return result;
631
665
  }
@@ -846,12 +880,18 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
846
880
  static VALUE serialize_stylesheet_with_nesting(
847
881
  VALUE rules_array,
848
882
  VALUE media_queries,
883
+ VALUE media_query_lists,
849
884
  VALUE result,
850
885
  const struct format_opts *opts
851
886
  ) {
852
887
  int formatted = (opts->decl_indent_base != NULL);
853
888
  long total_rules = RARRAY_LEN(rules_array);
854
889
 
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);
894
+
855
895
  // Build parent_to_children map (parent_rule_id -> array of child indices)
856
896
  // This allows O(1) lookup of children when serializing each parent
857
897
  VALUE parent_to_children = rb_hash_new();
@@ -930,7 +970,7 @@ static VALUE serialize_stylesheet_with_nesting(
930
970
  // Open new media block - store the MediaQuery object for comparison
931
971
  current_media = rule_media;
932
972
  rb_str_cat2(result, "@media ");
933
- 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);
934
974
  rb_str_cat2(result, " {\n");
935
975
  in_media_block = 1;
936
976
  }
@@ -1013,7 +1053,7 @@ static VALUE stylesheet_serialize_impl(VALUE rules_array, VALUE charset, VALUE h
1013
1053
 
1014
1054
  // Has nesting - use the parent/child lookahead serializer
1015
1055
  // 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);
1056
+ return serialize_stylesheet_with_nesting(rules_array, media_queries, media_query_lists, result, &opts);
1017
1057
  }
1018
1058
 
1019
1059
  // New stylesheet serialization entry point - checks for nesting and delegates
@@ -1077,78 +1117,6 @@ static VALUE new_parse_declarations_string(const char *start, const char *end) {
1077
1117
  return declarations;
1078
1118
  }
1079
1119
 
1080
- /*
1081
- * Convert array of Declaration structs to CSS string
1082
- * Format: "prop: value; prop2: value2 !important; "
1083
- *
1084
- * This is a copy of declarations_array_to_s from cataract.c,
1085
- * but works with Declaration structs instead of Declaration structs
1086
- */
1087
- static VALUE new_declarations_array_to_s(VALUE declarations_array) {
1088
- Check_Type(declarations_array, T_ARRAY);
1089
-
1090
- long len = RARRAY_LEN(declarations_array);
1091
- if (len == 0) {
1092
- return rb_str_new_cstr("");
1093
- }
1094
-
1095
- // Use rb_str_buf_new for efficient string building
1096
- VALUE result = rb_str_buf_new(len * 32); // Estimate 32 chars per declaration
1097
-
1098
- for (long i = 0; i < len; i++) {
1099
- VALUE decl = rb_ary_entry(declarations_array, i);
1100
-
1101
- // Validate this is a Declaration struct
1102
- if (!RB_TYPE_P(decl, T_STRUCT) || rb_obj_class(decl) != cDeclaration) {
1103
- rb_raise(rb_eTypeError,
1104
- "Expected array of Declaration structs, got %s at index %ld",
1105
- rb_obj_classname(decl), i);
1106
- }
1107
-
1108
- // Extract struct fields
1109
- VALUE property = rb_struct_aref(decl, INT2FIX(DECL_PROPERTY));
1110
- VALUE value = rb_struct_aref(decl, INT2FIX(DECL_VALUE));
1111
- VALUE important = rb_struct_aref(decl, INT2FIX(DECL_IMPORTANT));
1112
-
1113
- // Append: "property: value"
1114
- rb_str_buf_append(result, property);
1115
- rb_str_buf_cat2(result, ": ");
1116
- rb_str_buf_append(result, value);
1117
-
1118
- // Append " !important" if needed
1119
- if (RTEST(important)) {
1120
- rb_str_buf_cat2(result, " !important");
1121
- }
1122
-
1123
- rb_str_buf_cat2(result, "; ");
1124
-
1125
- RB_GC_GUARD(decl);
1126
- RB_GC_GUARD(property);
1127
- RB_GC_GUARD(value);
1128
- RB_GC_GUARD(important);
1129
- }
1130
-
1131
- // Strip trailing space
1132
- rb_str_set_len(result, RSTRING_LEN(result) - 1);
1133
-
1134
- RB_GC_GUARD(result);
1135
- return result;
1136
- }
1137
-
1138
- /*
1139
- * Instance method: Declarations#to_s
1140
- * Converts declarations to CSS string
1141
- *
1142
- * @return [String] CSS declarations like "color: red; margin: 10px !important;"
1143
- */
1144
- static VALUE new_declarations_to_s_method(VALUE self) {
1145
- // Get @values instance variable (array of Declaration structs)
1146
- VALUE values = rb_ivar_get(self, rb_intern("@values"));
1147
-
1148
- // Call core serialization function
1149
- return new_declarations_array_to_s(values);
1150
- }
1151
-
1152
1120
  /*
1153
1121
  * Ruby-facing wrapper for new_parse_declarations
1154
1122
  *
@@ -1239,23 +1207,24 @@ void Init_native_extension(void) {
1239
1207
  rb_raise(rb_eLoadError, "Cataract::MediaQuery not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1240
1208
  }
1241
1209
 
1242
- // Define Declarations class and add to_s method
1243
- VALUE cDeclarations = rb_define_class_under(mCataract, "Declarations", rb_cObject);
1244
- rb_define_method(cDeclarations, "to_s", new_declarations_to_s_method, 0);
1245
-
1246
- // 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.
1247
1213
  cStylesheet = rb_define_class_under(mCataract, "Stylesheet", rb_cObject);
1248
1214
 
1249
- // Define module functions
1250
- rb_define_module_function(mCataract, "_parse_css", parse_css_new, -1);
1251
- rb_define_module_function(mCataract, "stylesheet_to_s", stylesheet_to_s, 6);
1252
- rb_define_module_function(mCataract, "stylesheet_to_formatted_s", stylesheet_to_formatted_s, 6);
1253
- rb_define_module_function(mCataract, "parse_media_types", parse_media_types, 1);
1254
- rb_define_module_function(mCataract, "parse_declarations", new_parse_declarations, 1);
1255
- rb_define_module_function(mCataract, "flatten", cataract_flatten, 1);
1256
- rb_define_module_function(mCataract, "merge", cataract_flatten, 1); // Deprecated alias for backwards compatibility
1257
- rb_define_module_function(mCataract, "calculate_specificity", calculate_specificity, 1);
1258
- 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);
1259
1228
 
1260
1229
  // Initialize flatten constants (cached property strings)
1261
1230
  init_flatten_constants();
@@ -1275,11 +1244,11 @@ void Init_native_extension(void) {
1275
1244
  rb_hash_aset(compile_flags, ID2SYM(rb_intern("str_buf_optimization")), Qtrue);
1276
1245
  #endif
1277
1246
 
1278
- rb_define_const(mCataract, "COMPILE_FLAGS", compile_flags);
1247
+ rb_define_const(mNative, "COMPILE_FLAGS", compile_flags);
1279
1248
 
1280
- // Flag to indicate native extension is loaded (for pure Ruby fallback detection)
1281
- 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);
1282
1251
 
1283
1252
  // Implementation type constant
1284
- rb_define_const(mCataract, "IMPLEMENTATION", ID2SYM(rb_intern("native")));
1253
+ rb_define_const(mNative, "IMPLEMENTATION", ID2SYM(rb_intern("native")));
1285
1254
  }
@@ -262,7 +262,6 @@ static inline VALUE strip_string(const char *str, long len) {
262
262
  // CSS parser (css_parser_new.c)
263
263
  VALUE parse_css_new(int argc, VALUE *argv, VALUE self);
264
264
  VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_offset);
265
- VALUE parse_media_types(VALUE self, VALUE media_query_sym);
266
265
 
267
266
  // Flatten (flatten.c)
268
267
  VALUE cataract_flatten(VALUE self, VALUE rules_array);
@@ -2312,24 +2312,6 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2312
2312
  }
2313
2313
  }
2314
2314
 
2315
- /*
2316
- * Parse media query string and extract media types (Ruby-facing function)
2317
- * Example: "screen, print" => [:screen, :print]
2318
- * Example: "screen and (min-width: 768px)" => [:screen]
2319
- *
2320
- * @param media_query_sym [Symbol] Media query as symbol
2321
- * @return [Array<Symbol>] Array of media type symbols
2322
- */
2323
- VALUE parse_media_types(VALUE self, VALUE media_query_sym) {
2324
- Check_Type(media_query_sym, T_SYMBOL);
2325
-
2326
- VALUE query_string = rb_sym2str(media_query_sym);
2327
- const char *query_str = RSTRING_PTR(query_string);
2328
- long query_len = RSTRING_LEN(query_string);
2329
-
2330
- return extract_media_types(query_str, query_len);
2331
- }
2332
-
2333
2315
  /*
2334
2316
  * Main parse entry point
2335
2317
  * Returns: { rules: [...], media_index: {...}, charset: "..." | nil, last_rule_id: N }
@@ -198,12 +198,22 @@ module Cataract
198
198
 
199
199
  # Convert declarations to CSS string.
200
200
  #
201
- # Implemented in C for performance.
202
- #
203
201
  # @return [String] CSS declaration block string
204
202
  #
205
203
  # @example
206
204
  # decls.to_s #=> "color: red; margin: 10px !important;"
205
+ def to_s
206
+ result = +''
207
+ @values.each_with_index do |decl, i|
208
+ result << decl.property
209
+ result << ': '
210
+ result << decl.value
211
+ result << ' !important' if decl.important
212
+ result << ';'
213
+ result << ' ' if i < @values.length - 1 # Add space after semicolon except for last
214
+ end
215
+ result
216
+ end
207
217
 
208
218
  # Enable implicit string conversion for comparisons
209
219
  alias to_str to_s
@@ -290,7 +300,7 @@ module Cataract
290
300
  # @param source [Declarations] Source Declarations being copied
291
301
  def initialize_copy(source)
292
302
  super
293
- @values = source.instance_variable_get(:@values).dup
303
+ @values = source.to_a.dup
294
304
  end
295
305
 
296
306
  # Compare this Declarations with another object.
@@ -331,7 +341,7 @@ module Cataract
331
341
 
332
342
  # Parse "color: red; background: blue" string into array of Declaration structs
333
343
  def parse_declaration_string(str)
334
- Cataract.parse_declarations(str)
344
+ Cataract::Backends.active.parse_declarations(str)
335
345
  end
336
346
  end
337
347
  end