cataract 0.1.4 → 0.2.1

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: b8c9aa122ff45945bef0a05d6fa6a5dcfd46252ffe5970bf288074db5d3514c9
4
- data.tar.gz: cdafcb1ca599a58449dc03c239e5b8891f4399629dbdf7ca4e3ef23fb7f6ab94
3
+ metadata.gz: a1c4727f2f1b3c3aafa117ca105ee0d55a6d6f33b0fed789533af10c24173918
4
+ data.tar.gz: 86c0e96b1a953fb817fa235517e00215b4b363baea7d1225c5fcb2b9643b29c9
5
5
  SHA512:
6
- metadata.gz: 35bbffc26c5eb757a90935fd0caf362818d80cc33af0860c738061ca3d0047f6b6d5c8124087d8283c0bb93b26699990279f98af96215456099a493d1e4720ee
7
- data.tar.gz: 567ffe7ce56412c5913384ce8b6470b9d8d0e7e880463e2024a6d4cd6fca4bcb9b792ce0ca890aea1edf07ed218497a8369058f38b8b30ab94bee63694ef03e9
6
+ metadata.gz: 7282bde75fb5cd81ad2b7f2d7134a6fde2941a8f5320a4de2d463114d25bfc709e80f1639e9479cb69b7f22d685d203a7097fa0cf8c2e952041b733420991080
7
+ data.tar.gz: f8af1d50b389d64c36c5138f431b5b09f95ec3ca8f68b6c342ec280e7e1c84a8fca95ec6c95a81d97f9bced8d0f295acfbf2849ca47939e16cbf1b408544cdbe
@@ -4,12 +4,29 @@ on:
4
4
  workflow_dispatch:
5
5
 
6
6
  jobs:
7
+ test-jruby:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+
12
+ - name: Set up Ruby
13
+ uses: ruby/setup-ruby@v1
14
+ with:
15
+ ruby-version: jruby
16
+ bundler-cache: true
17
+
18
+ - name: Display Ruby version
19
+ run: ruby --version
20
+
21
+ - name: Run tests
22
+ run: bundle exec rake test:pure
23
+
7
24
  test-alt-rubies:
8
25
  runs-on: ubuntu-latest
9
26
  strategy:
10
27
  fail-fast: false
11
28
  matrix:
12
- ruby: [jruby, truffleruby]
29
+ ruby: [truffleruby, ruby-head]
13
30
 
14
31
  steps:
15
32
  - uses: actions/checkout@v4
data/.rubocop.yml CHANGED
@@ -1,5 +1,8 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
+ require:
4
+ - ./.rubocop/cataract/ban_assert_includes.rb
5
+
3
6
  plugins:
4
7
  - rubocop-performance
5
8
  - rubocop-minitest
@@ -66,13 +69,17 @@ Layout/LineLength:
66
69
  Minitest/MultipleAssertions:
67
70
  Max: 10
68
71
 
69
- Style/Documentation:
70
- Enabled: true
71
- Exclude:
72
+ # Disable ambiguous block association warnings in tests
73
+ # Tests frequently use assert/refute with blocks that call methods with their own blocks,
74
+ # e.g., `assert sheet.rules.any? { |r| r == '.box { color: red; }' }`
75
+ # This compares Rule objects against CSS strings using our custom Rule#== implementation.
76
+ # While this triggers the ambiguous block warning, it's intentional and clear in context.
77
+ Lint/AmbiguousBlockAssociation:
78
+ Exclude:
72
79
  - 'test/**/*'
73
- - 'benchmarks/**/*'
74
- - 'lib/cataract/pure/**/*.rb'
75
- - 'lib/cataract/pure.rb'
80
+
81
+ Style/Documentation:
82
+ Enabled: false
76
83
 
77
84
  # Disable modifier if/unless enforcement - use it when it's clearer, not because a cop says so
78
85
  Style/IfUnlessModifier:
@@ -143,3 +150,26 @@ Style/MapIntoArray:
143
150
  Style/OptionalBooleanParameter:
144
151
  Exclude:
145
152
  - 'lib/cataract/pure/**/*.rb'
153
+
154
+ Style/StderrPuts:
155
+ Exclude:
156
+ - 'scripts/**/*.rb'
157
+
158
+ # Disable all performance cops in tests - test clarity is more important than micro-optimizations
159
+ # Performance suggestions often hurt readability without meaningful benefit in test code
160
+ Performance:
161
+ Exclude:
162
+ - 'test/**/*'
163
+
164
+ Style/HashLikeCase:
165
+ Exclude:
166
+ - 'benchmarks/**/*'
167
+
168
+ # Custom Cataract cops
169
+ Cataract/BanAssertIncludes:
170
+ Enabled: true
171
+ Include:
172
+ - 'test/**/*'
173
+ Exclude:
174
+ - 'test/test_benchmark_doc_generator.rb'
175
+ - 'test/support/**/*' # Support files define assert_contains which uses assert_includes internally
data/.rubocop_todo.yml CHANGED
@@ -34,13 +34,13 @@ Layout/MultilineOperationIndentation:
34
34
  # This cop supports safe autocorrection (--autocorrect).
35
35
  Lint/AmbiguousOperatorPrecedence:
36
36
  Exclude:
37
- - 'lib/cataract/pure/merge.rb'
37
+ - 'lib/cataract/pure/flatten.rb'
38
38
 
39
39
  # Offense count: 2
40
40
  # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
41
41
  Lint/DuplicateBranch:
42
42
  Exclude:
43
- - 'lib/cataract/pure/merge.rb'
43
+ - 'lib/cataract/pure/flatten.rb'
44
44
 
45
45
  # Offense count: 1
46
46
  Lint/IneffectiveAccessModifier:
@@ -74,7 +74,7 @@ Minitest/EmptyLineBeforeAssertionMethods:
74
74
  Performance/UnfreezeString:
75
75
  Exclude:
76
76
  - 'lib/cataract/pure.rb'
77
- - 'lib/cataract/pure/merge.rb'
77
+ - 'lib/cataract/pure/flatten.rb'
78
78
  - 'lib/cataract/pure/parser.rb'
79
79
 
80
80
  # Offense count: 4
@@ -84,7 +84,7 @@ Performance/UnfreezeString:
84
84
  Style/ConditionalAssignment:
85
85
  Exclude:
86
86
  - 'lib/cataract/pure/imports.rb'
87
- - 'lib/cataract/pure/merge.rb'
87
+ - 'lib/cataract/pure/flatten.rb'
88
88
 
89
89
  # Offense count: 4
90
90
  # Configuration parameters: AllowedConstants.
@@ -123,7 +123,7 @@ Style/IdenticalConditionalBranches:
123
123
  # Configuration parameters: AllowIfModifier.
124
124
  Style/IfInsideElse:
125
125
  Exclude:
126
- - 'lib/cataract/pure/merge.rb'
126
+ - 'lib/cataract/pure/flatten.rb'
127
127
  - 'lib/cataract/pure/serializer.rb'
128
128
  - 'lib/cataract/pure/specificity.rb'
129
129
 
@@ -131,7 +131,7 @@ Style/IfInsideElse:
131
131
  # This cop supports safe autocorrection (--autocorrect).
132
132
  Style/NegatedIfElseCondition:
133
133
  Exclude:
134
- - 'lib/cataract/pure/merge.rb'
134
+ - 'lib/cataract/pure/flatten.rb'
135
135
 
136
136
  # Offense count: 1
137
137
  # This cop supports safe autocorrection (--autocorrect).
@@ -157,7 +157,7 @@ Style/RedundantRegexpArgument:
157
157
  # Configuration parameters: AllowModifier.
158
158
  Style/SoleNestedConditional:
159
159
  Exclude:
160
- - 'lib/cataract/pure/merge.rb'
160
+ - 'lib/cataract/pure/flatten.rb'
161
161
  - 'lib/cataract/pure/parser.rb'
162
162
 
163
163
  # Offense count: 3
data/BENCHMARKS.md CHANGED
@@ -20,15 +20,15 @@ Time to parse CSS into internal data structures
20
20
 
21
21
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) | css_parser (no YJIT) | css_parser (YJIT) |
22
22
  |-----------|--------|----------------|-------------|----------------------|-------------------|
23
- | Small CSS (64 lines, 1.0KB) | 63.2K i/s | 3.56K i/s | 15.49K i/s | 4.7K i/s | 6.39K i/s |
24
- | Medium CSS with @media (139 lines, 1.6KB) | 40.14K i/s | 2.07K i/s | 8.76K i/s | 2.81K i/s | 3.54K i/s |
23
+ | Small CSS (64 lines, 1.0KB) | 62.47K i/s | 3.59K i/s | 15.67K i/s | 4.73K i/s | 6.17K i/s |
24
+ | Medium CSS with @media (139 lines, 1.6KB) | 39.7K i/s | 2.05K i/s | 8.64K i/s | 2.76K i/s | 3.52K i/s |
25
25
 
26
26
  ### Speedups
27
27
 
28
28
  | Comparison | Speedup |
29
29
  |------------|---------|
30
- | Native vs Pure (no YJIT) | 18.58x faster (avg) |
31
- | Native vs Pure (YJIT) | 4.26x faster (avg) |
30
+ | Native vs Pure (no YJIT) | 18.4x faster (avg) |
31
+ | Native vs Pure (YJIT) | 4.2x faster (avg) |
32
32
  | YJIT impact on Pure Ruby | 4.31x faster (avg) |
33
33
 
34
34
  ---
@@ -39,16 +39,16 @@ Time to convert parsed CSS back to string format
39
39
 
40
40
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) | css_parser (no YJIT) | css_parser (YJIT) |
41
41
  |-----------|--------|----------------|-------------|----------------------|-------------------|
42
- | Full Serialization (Bootstrap CSS - 191KB) | 1.79K i/s | 442.2 i/s | 716.9 i/s | 38.5 i/s | 39.7 i/s |
43
- | Media Type Filtering (print only) | 282.98K i/s | 114.55K i/s | 167.51K i/s | 2.74K i/s | 4.32K i/s |
42
+ | Full Serialization (Bootstrap CSS - 191KB) | 1.77K i/s | 444.3 i/s | 676.3 i/s | 37.1 i/s | 35.9 i/s |
43
+ | Media Type Filtering (print only) | 282.58K i/s | 114.27K i/s | 155.82K i/s | 2.67K i/s | 4.03K i/s |
44
44
 
45
45
  ### Speedups
46
46
 
47
47
  | Comparison | Speedup |
48
48
  |------------|---------|
49
- | Native vs Pure (no YJIT) | 3.26x faster (avg) |
50
- | Native vs Pure (YJIT) | 1.69x faster (avg) |
51
- | YJIT impact on Pure Ruby | 1.46x faster (avg) |
49
+ | Native vs Pure (no YJIT) | 3.23x faster (avg) |
50
+ | Native vs Pure (YJIT) | 1.82x faster (avg) |
51
+ | YJIT impact on Pure Ruby | 1.36x faster (avg) |
52
52
 
53
53
  ---
54
54
 
@@ -58,43 +58,43 @@ Time to calculate CSS selector specificity values
58
58
 
59
59
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) | css_parser (no YJIT) | css_parser (YJIT) |
60
60
  |-----------|--------|----------------|-------------|----------------------|-------------------|
61
- | Simple Selectors | 8.31M i/s | 499.15K i/s | 2.59M i/s | 370.14K i/s | 344.42K i/s |
62
- | Compound Selectors | 6.57M i/s | 184.61K i/s | 403.41K i/s | 223.76K i/s | 204.19K i/s |
63
- | Combinators | 5.24M i/s | 144.7K i/s | 253.9K i/s | 188.05K i/s | 177.88K i/s |
64
- | Pseudo-classes & Pseudo-elements | 5.39M i/s | 115.46K i/s | 194.13K i/s | 116.96K i/s | 109.64K i/s |
65
- | :not() Pseudo-class (CSS3) | 3.31M i/s | 101.48K i/s | 160.62K i/s | 144.35K i/s | 141.24K i/s |
66
- | Complex Real-world Selectors | 4.09M i/s | 51.63K i/s | 77.37K i/s | 82.16K i/s | 80.6K i/s |
61
+ | Simple Selectors | 7.35M i/s | 506.04K i/s | 2.59M i/s | 357.72K i/s | 353.42K i/s |
62
+ | Compound Selectors | 6.36M i/s | 184.96K i/s | 406.99K i/s | 216.59K i/s | 214.86K i/s |
63
+ | Combinators | 5.04M i/s | 146.91K i/s | 252.94K i/s | 185.44K i/s | 183.71K i/s |
64
+ | Pseudo-classes & Pseudo-elements | 5.1M i/s | 116.46K i/s | 197.06K i/s | 113.05K i/s | 113.99K i/s |
65
+ | :not() Pseudo-class (CSS3) | 3.23M i/s | 102.63K i/s | 161.55K i/s | 144.04K i/s | 144.53K i/s |
66
+ | Complex Real-world Selectors | 4.01M i/s | 52.03K i/s | 77.65K i/s | 83.91K i/s | 82.03K i/s |
67
67
 
68
68
  ### Speedups
69
69
 
70
70
  | Comparison | Speedup |
71
71
  |------------|---------|
72
- | Native vs Pure (no YJIT) | 41.16x faster (avg) |
73
- | Native vs Pure (YJIT) | 8.94x faster (avg) |
74
- | YJIT impact on Pure Ruby | 3.36x faster (avg) |
72
+ | Native vs Pure (no YJIT) | 39.27x faster (avg) |
73
+ | Native vs Pure (YJIT) | 8.43x faster (avg) |
74
+ | YJIT impact on Pure Ruby | 3.32x faster (avg) |
75
75
 
76
76
  ---
77
77
 
78
- ## CSS Merging
78
+ ## CSS Flattening (Cascade)
79
79
 
80
- Time to merge multiple CSS rule sets with same selector
80
+ Time to flatten multiple CSS rule sets with same selector
81
81
 
82
82
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) | css_parser (no YJIT) | css_parser (YJIT) |
83
83
  |-----------|--------|----------------|-------------|----------------------|-------------------|
84
- | No shorthand properties (large) | 10.5K i/s | 3.46K i/s | 6.73K i/s | 1.51K i/s | 2.27K i/s |
85
- | Simple properties | 125.51K i/s | 77.36K i/s | 110.86K i/s | 27.92K i/s | 40.42K i/s |
86
- | Cascade with specificity | 140.46K i/s | 80.08K i/s | 116.35K i/s | 31.48K i/s | 45.99K i/s |
87
- | Important declarations | 139.59K i/s | 80.29K i/s | 116.39K i/s | 30.84K i/s | 44.87K i/s |
88
- | Shorthand expansion | 10.5K i/s | 3.46K i/s | 6.73K i/s | 1.51K i/s | 2.27K i/s |
89
- | Complex merging | 24.74K i/s | 16.62K i/s | 23.57K i/s | 11.59K i/s | 16.48K i/s |
84
+ | No shorthand properties (large) | 21.34K i/s | 3.11K i/s | 5.23K i/s | 1.58K i/s | 2.3K i/s |
85
+ | Simple properties | 158.81K i/s | 75.72K i/s | 102.44K i/s | 28.29K i/s | 40.72K i/s |
86
+ | Cascade with specificity | 204.11K i/s | 77.93K i/s | 108.77K i/s | 31.92K i/s | 46.39K i/s |
87
+ | Important declarations | 203.43K i/s | 77.88K i/s | 109.38K i/s | 31.25K i/s | 45.25K i/s |
88
+ | Shorthand expansion | 21.34K i/s | 3.11K i/s | 5.23K i/s | 1.58K i/s | 2.3K i/s |
89
+ | Complex merging | 30.94K i/s | 16.09K i/s | 21.57K i/s | 11.6K i/s | 16.63K i/s |
90
90
 
91
91
  ### Speedups
92
92
 
93
93
  | Comparison | Speedup |
94
94
  |------------|---------|
95
- | Native vs Pure (no YJIT) | 1.87x faster (avg) |
96
- | Native vs Pure (YJIT) | 1.16x faster (avg) |
97
- | YJIT impact on Pure Ruby | 1.45x faster (avg) |
95
+ | Native vs Pure (no YJIT) | 3.03x faster (avg) |
96
+ | Native vs Pure (YJIT) | 1.72x faster (avg) |
97
+ | YJIT impact on Pure Ruby | 1.38x faster (avg) |
98
98
 
99
99
  ---
100
100
 
@@ -108,7 +108,7 @@ rake benchmark
108
108
  rake benchmark:parsing
109
109
  rake benchmark:serialization
110
110
  rake benchmark:specificity
111
- rake benchmark:merging
111
+ rake benchmark:flattening
112
112
 
113
113
  # Generate documentation
114
114
  rake benchmark:generate_docs
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.2.1] - 2025-11-14
2
+
3
+ - Fix serializer bug related to media queries
4
+
5
+ ## [0.2.0] - 2025-11-14
6
+
7
+ - Major: CSS `@import` resolution refactored from string-concatenation to parsed-object architecture with proper charset handling, media query combining,
8
+ and circular import detection
9
+ - Major: Terminology change: all `merge` methods renamed to `flatten` to better represent CSS cascade behavior (old names deprecated with warnings)
10
+ - Major: Rule equality now considers shorthand/longhand property equivalence (e.g., `margin: 10px` equals `margin-top: 10px; margin-right: 10px; ...`)
11
+ - Performance: Flatten operation optimized with array-based property storage, pre-allocated frozen strings, and lazy specificity calculation
12
+ - Feature: New Stylesheet collection methods (`+`, `-`, `|`, `concat`, `take`, `take_while`) with cascade rules applied
13
+ - Feature: Added source order tracking for proper CSS cascade resolution
14
+
1
15
  ## [0.1.4] - 2025-11-12
2
16
  - Major: Pure Ruby implementation added (#12)
3
17
  - Fix: Media query serialization bugs - parentheses now preserved per CSS spec (min-width: 768px), fixed media query ordering
data/RAGEL_MIGRATION.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Why We Switched
4
4
 
5
- Started with Ragel as an experiment for the CSS parser, but quickly moved to hand-written pure C in October 2024.
5
+ Started with Ragel as an experiment for the CSS parser, but quickly moved to hand-written pure C in October 2025.
6
6
 
7
7
  ### Performance
8
8
 
@@ -55,6 +55,6 @@ Pure C is more verbose than Ragel's DSL, but the performance gains and simpler b
55
55
 
56
56
  ## Details
57
57
 
58
- - Swapped in October 2024
58
+ - Swapped in October 2025
59
59
  - Files: `css_parser.c`, `specificity.c`, `value_splitter.c`
60
60
  - API unchanged, all tests pass
data/README.md CHANGED
@@ -181,7 +181,10 @@ Cataract parses and preserves all standard CSS including:
181
181
 
182
182
  Cataract supports converting colors between multiple CSS color formats with high precision.
183
183
 
184
- **Note:** Color conversion is an optional extension. Load it explicitly to reduce memory footprint:
184
+ **Note:** Color conversion is an optional extension and _not_ loaded by default.
185
+
186
+ <details>
187
+ <summary>Color conversion examples and supported formats</summary>
185
188
 
186
189
  ```ruby
187
190
  require 'cataract'
@@ -241,6 +244,8 @@ sheet.convert_colors!(to: :hex) # Converts all formats to hex
241
244
  - CSS Color Level 5 features (`none`, `infinity`, relative color syntax with `from`) are preserved but not converted
242
245
  - Unknown or future color functions are passed through unchanged
243
246
 
247
+ </details>
248
+
244
249
  ### `@import` Support
245
250
 
246
251
  `@import` statements can be resolved with security controls:
@@ -301,7 +306,7 @@ Each `Rule` is a struct containing:
301
306
  - `specificity`: Calculated CSS specificity (cached)
302
307
 
303
308
  Implementation details:
304
- - **C implementation**: Critical paths implemented in C (parsing, merging, serialization)
309
+ - **C implementation**: Critical paths implemented in C (parsing, cascade/flatten, serialization)
305
310
  - **Flat rule array**: All rules stored in a single array, preserving source order
306
311
  - **Efficient media query handling**: O(1) lookup via internal media index
307
312
  - **Memory efficient**: Minimal allocations, reuses string buffers where possible
data/Rakefile CHANGED
@@ -79,7 +79,7 @@ task :benchmark do
79
79
  Rake::Task['benchmark:parsing'].invoke
80
80
  Rake::Task['benchmark:serialization'].invoke
81
81
  Rake::Task['benchmark:specificity'].invoke
82
- Rake::Task['benchmark:merging'].invoke
82
+ Rake::Task['benchmark:flattening'].invoke
83
83
  puts "\n#{'-' * 80}"
84
84
  puts 'All benchmarks complete!'
85
85
  puts 'Generate documentation with: rake benchmark:generate_docs'
@@ -105,10 +105,10 @@ namespace :benchmark do
105
105
  ruby 'benchmarks/benchmark_specificity.rb'
106
106
  end
107
107
 
108
- desc 'Benchmark CSS merging performance'
109
- task merging: :compile do
110
- puts 'Running merging benchmark...'
111
- ruby 'benchmarks/benchmark_merging.rb'
108
+ desc 'Benchmark CSS flattening performance'
109
+ task flattening: :compile do
110
+ puts 'Running flattening benchmark...'
111
+ ruby 'benchmarks/benchmark_flattening.rb'
112
112
  end
113
113
 
114
114
  desc 'Benchmark string allocation optimization (buffer vs dynamic)'
@@ -195,14 +195,27 @@ task :lint do
195
195
  end
196
196
 
197
197
  # Fuzz testing
198
- desc 'Run fuzzer to test parser robustness (including color conversion)'
199
- task fuzz: :compile do
200
- iterations = ENV['ITERATIONS'] || '10000'
201
- puts "Running CSS parser fuzzer (#{iterations} iterations)..."
202
- # Use system with ENV.to_h to preserve environment variables like FUZZ_GC_STRESS
203
- system(ENV.to_h, RbConfig.ruby, '-Ilib', 'scripts/fuzzer/run.rb', iterations)
198
+ namespace :fuzz do
199
+ desc 'Run fuzzer with C extension'
200
+ task c: :compile do
201
+ iterations = ENV['ITERATIONS'] || '10000'
202
+ puts "Running CSS parser fuzzer with C extension (#{iterations} iterations)..."
203
+ system(ENV.to_h, RbConfig.ruby, '-Ilib', 'scripts/fuzzer/run.rb', iterations)
204
+ end
205
+
206
+ desc 'Run fuzzer with pure Ruby implementation'
207
+ task :pure do
208
+ iterations = ENV['ITERATIONS'] || '10000'
209
+ debug_msg = ENV['FUZZ_DEBUG'] == '1' ? ' (debug mode)' : ''
210
+ puts "Running CSS parser fuzzer with pure Ruby (#{iterations} iterations#{debug_msg})..."
211
+ env = ENV.to_h.merge('CATARACT_PURE' => '1')
212
+ system(env, RbConfig.ruby, '-Ilib', 'scripts/fuzzer/run.rb', iterations)
213
+ end
204
214
  end
205
215
 
216
+ desc 'Run fuzzer with both C extension and pure Ruby'
217
+ task fuzz: ['fuzz:c', 'fuzz:pure']
218
+
206
219
  # Documentation generation with YARD
207
220
  begin
208
221
  require 'yard'
data/cataract.gemspec CHANGED
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
22
22
  # Specify which files should be added to the gem when it is released.
23
23
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
24
  `git ls-files -z`.split("\x0").reject do |f|
25
- f.match(%r{^(test|spec|features|benchmarks|scripts)/}) ||
25
+ f.match(%r{^(test|spec|features|benchmarks|scripts|\.rubocop)/}) ||
26
26
  f.match(/^(test|benchmark)_.*\.rb$/)
27
27
  end
28
28
  end
@@ -7,6 +7,7 @@ VALUE cRule;
7
7
  VALUE cDeclaration;
8
8
  VALUE cAtRule;
9
9
  VALUE cStylesheet;
10
+ VALUE cImportStatement;
10
11
 
11
12
  // Error class definitions (shared with main extension)
12
13
  VALUE eCataractError;
@@ -548,18 +549,34 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
548
549
 
549
550
  if (formatted) {
550
551
  // Formatted output with indentation
552
+ DEBUG_PRINTF("[SERIALIZE_RULE] Formatted mode, indent_level=%d, selector=%s\n", indent_level, RSTRING_PTR(selector));
551
553
  rb_str_append(result, selector);
552
554
  rb_str_cat2(result, " {\n");
553
555
 
556
+ // Build indent strings based on indent_level
557
+ // Declarations are inside the rule, so add 1 level (2 spaces per level)
558
+ // Closing brace matches the opening selector level
559
+ char decl_indent[MAX_INDENT_BUFFER];
560
+ char closing_indent[MAX_INDENT_BUFFER];
561
+ int decl_spaces = (indent_level + 1) * 2;
562
+ int closing_spaces = indent_level * 2;
563
+ memset(decl_indent, ' ', decl_spaces);
564
+ decl_indent[decl_spaces] = '\0';
565
+ memset(closing_indent, ' ', closing_spaces);
566
+ closing_indent[closing_spaces] = '\0';
567
+
554
568
  // Serialize own declarations with indentation (each on its own line)
555
569
  if (!NIL_P(declarations) && RARRAY_LEN(declarations) > 0) {
556
- serialize_declarations_formatted(result, declarations, " ");
570
+ DEBUG_PRINTF("[SERIALIZE_RULE] Serializing %ld declarations with indent='%s' (%d spaces)\n",
571
+ RARRAY_LEN(declarations), decl_indent, decl_spaces);
572
+ serialize_declarations_formatted(result, declarations, decl_indent);
557
573
  }
558
574
 
559
575
  // Serialize nested children
560
576
  serialize_children_only(result, rules_array, rule_idx, rule_to_media, parent_to_children,
561
577
  selector, declarations, formatted, indent_level + 1);
562
578
 
579
+ rb_str_cat2(result, closing_indent);
563
580
  rb_str_cat2(result, "}\n");
564
581
  } else {
565
582
  // Compact output
@@ -575,6 +592,9 @@ static void serialize_rule_with_children(VALUE result, VALUE rules_array, long r
575
592
 
576
593
  rb_str_cat2(result, " }\n");
577
594
  }
595
+
596
+ // Prevent compiler from optimizing away 'rule' before we're done with selector/declarations
597
+ RB_GC_GUARD(rule);
578
598
  }
579
599
 
580
600
  // New stylesheet serialization entry point - checks for nesting and delegates
@@ -625,6 +645,10 @@ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_inde
625
645
 
626
646
  DEBUG_PRINTF("[MAP] parent_to_children map: %s\n", RSTRING_PTR(rb_inspect(parent_to_children)));
627
647
 
648
+ // Track media block state for proper opening/closing
649
+ VALUE current_media = Qnil;
650
+ int in_media_block = 0;
651
+
628
652
  // Serialize only top-level rules (parent_rule_id == nil)
629
653
  // Children are serialized recursively
630
654
  DEBUG_PRINTF("[SERIALIZE] Starting serialization, total_rules=%ld\n", total_rules);
@@ -642,6 +666,34 @@ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_inde
642
666
  continue;
643
667
  }
644
668
 
669
+ // Get media for this rule
670
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
671
+ VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
672
+
673
+ // Handle media block transitions
674
+ if (NIL_P(rule_media)) {
675
+ // Not in media - close any open media block
676
+ if (in_media_block) {
677
+ rb_str_cat2(result, "}\n");
678
+ in_media_block = 0;
679
+ current_media = Qnil;
680
+ }
681
+ } else {
682
+ // In media - check if we need to open/change block
683
+ if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
684
+ // Close previous media block if open
685
+ if (in_media_block) {
686
+ rb_str_cat2(result, "}\n");
687
+ }
688
+ // Open new media block
689
+ current_media = rule_media;
690
+ rb_str_cat2(result, "@media ");
691
+ rb_str_append(result, rb_sym2str(rule_media));
692
+ rb_str_cat2(result, " {\n");
693
+ in_media_block = 1;
694
+ }
695
+ }
696
+
645
697
  // Check if this is an AtRule
646
698
  if (rb_obj_is_kind_of(rule, cAtRule)) {
647
699
  serialize_at_rule(result, rule);
@@ -656,6 +708,11 @@ static VALUE stylesheet_to_s_new(VALUE self, VALUE rules_array, VALUE media_inde
656
708
  );
657
709
  }
658
710
 
711
+ // Close final media block if still open
712
+ if (in_media_block) {
713
+ rb_str_cat2(result, "}\n");
714
+ }
715
+
659
716
  return result;
660
717
  }
661
718
 
@@ -778,6 +835,10 @@ static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE
778
835
  }
779
836
  }
780
837
 
838
+ // Track media block state for proper opening/closing
839
+ VALUE current_media = Qnil;
840
+ int in_media_block = 0;
841
+
781
842
  // Serialize only top-level rules (parent_rule_id == nil)
782
843
  for (long i = 0; i < total_rules; i++) {
783
844
  VALUE rule = rb_ary_entry(rules_array, i);
@@ -788,20 +849,66 @@ static VALUE stylesheet_to_formatted_s_new(VALUE self, VALUE rules_array, VALUE
788
849
  continue;
789
850
  }
790
851
 
852
+ // Get media for this rule
853
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
854
+ VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
855
+
856
+ // Handle media block transitions
857
+ if (NIL_P(rule_media)) {
858
+ // Not in media - close any open media block
859
+ if (in_media_block) {
860
+ rb_str_cat2(result, "}\n");
861
+ in_media_block = 0;
862
+ current_media = Qnil;
863
+
864
+ // Add blank line after closing media block
865
+ rb_str_cat2(result, "\n");
866
+ }
867
+ } else {
868
+ // In media - check if we need to open/change block
869
+ if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
870
+ // Close previous media block if open
871
+ if (in_media_block) {
872
+ rb_str_cat2(result, "}\n");
873
+ } else if (RSTRING_LEN(result) > 0) {
874
+ // Add blank line before new media block (except at start)
875
+ rb_str_cat2(result, "\n");
876
+ }
877
+ // Open new media block
878
+ current_media = rule_media;
879
+ rb_str_cat2(result, "@media ");
880
+ rb_str_append(result, rb_sym2str(rule_media));
881
+ rb_str_cat2(result, " {\n");
882
+ in_media_block = 1;
883
+ }
884
+ }
885
+
791
886
  // Check if this is an AtRule
792
887
  if (rb_obj_is_kind_of(rule, cAtRule)) {
793
888
  serialize_at_rule(result, rule);
794
889
  continue;
795
890
  }
796
891
 
892
+ // Add indent if inside media block
893
+ if (in_media_block) {
894
+ DEBUG_PRINTF("[FORMATTED] Adding base indent for media block\n");
895
+ rb_str_cat2(result, " ");
896
+ }
897
+
797
898
  // Serialize rule with nested children
899
+ DEBUG_PRINTF("[FORMATTED] Calling serialize_rule_with_children, in_media_block=%d\n", in_media_block);
798
900
  serialize_rule_with_children(
799
901
  result, rules_array, i, rule_to_media, parent_to_children,
800
902
  1, // formatted (with indentation)
801
- 0 // indent_level (top-level)
903
+ in_media_block ? 1 : 0 // indent_level (1 if inside media block, 0 otherwise)
802
904
  );
803
905
  }
804
906
 
907
+ // Close final media block if still open
908
+ if (in_media_block) {
909
+ rb_str_cat2(result, "}\n");
910
+ }
911
+
805
912
  return result;
806
913
  }
807
914
 
@@ -1044,6 +1151,12 @@ void Init_native_extension(void) {
1044
1151
  rb_raise(rb_eLoadError, "Cataract::AtRule not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1045
1152
  }
1046
1153
 
1154
+ if (rb_const_defined(mCataract, rb_intern("ImportStatement"))) {
1155
+ cImportStatement = rb_const_get(mCataract, rb_intern("ImportStatement"));
1156
+ } else {
1157
+ rb_raise(rb_eLoadError, "Cataract::ImportStatement not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1158
+ }
1159
+
1047
1160
  // Define Declarations class and add to_s method
1048
1161
  VALUE cDeclarations = rb_define_class_under(mCataract, "Declarations", rb_cObject);
1049
1162
  rb_define_method(cDeclarations, "to_s", new_declarations_to_s_method, 0);
@@ -1057,12 +1170,14 @@ void Init_native_extension(void) {
1057
1170
  rb_define_module_function(mCataract, "_stylesheet_to_formatted_s", stylesheet_to_formatted_s_new, 4);
1058
1171
  rb_define_module_function(mCataract, "parse_media_types", parse_media_types, 1);
1059
1172
  rb_define_module_function(mCataract, "parse_declarations", new_parse_declarations, 1);
1060
- rb_define_module_function(mCataract, "merge", cataract_merge_new, 1);
1173
+ rb_define_module_function(mCataract, "flatten", cataract_flatten, 1);
1174
+ rb_define_module_function(mCataract, "merge", cataract_flatten, 1); // Deprecated alias for backwards compatibility
1061
1175
  rb_define_module_function(mCataract, "extract_imports", extract_imports, 1);
1062
1176
  rb_define_module_function(mCataract, "calculate_specificity", calculate_specificity, 1);
1177
+ rb_define_module_function(mCataract, "_expand_shorthand", cataract_expand_shorthand, 1);
1063
1178
 
1064
- // Initialize merge constants (cached property strings)
1065
- init_merge_constants();
1179
+ // Initialize flatten constants (cached property strings)
1180
+ init_flatten_constants();
1066
1181
 
1067
1182
  // Export compile-time flags as a hash for runtime introspection
1068
1183
  VALUE compile_flags = rb_hash_new();