cataract 0.1.2 → 0.1.4

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +27 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +62 -0
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +60 -139
  7. data/CHANGELOG.md +14 -0
  8. data/README.md +30 -2
  9. data/Rakefile +49 -22
  10. data/cataract.gemspec +4 -1
  11. data/ext/cataract/cataract.c +47 -47
  12. data/ext/cataract/css_parser.c +17 -33
  13. data/ext/cataract/merge.c +58 -2
  14. data/lib/cataract/at_rule.rb +8 -9
  15. data/lib/cataract/declaration.rb +18 -0
  16. data/lib/cataract/import_resolver.rb +3 -4
  17. data/lib/cataract/pure/byte_constants.rb +69 -0
  18. data/lib/cataract/pure/helpers.rb +35 -0
  19. data/lib/cataract/pure/imports.rb +255 -0
  20. data/lib/cataract/pure/merge.rb +1146 -0
  21. data/lib/cataract/pure/parser.rb +1236 -0
  22. data/lib/cataract/pure/serializer.rb +590 -0
  23. data/lib/cataract/pure/specificity.rb +206 -0
  24. data/lib/cataract/pure.rb +130 -0
  25. data/lib/cataract/rule.rb +22 -13
  26. data/lib/cataract/stylesheet.rb +14 -9
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +18 -5
  29. metadata +12 -25
  30. data/benchmarks/benchmark_harness.rb +0 -193
  31. data/benchmarks/benchmark_merging.rb +0 -121
  32. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  33. data/benchmarks/benchmark_parsing.rb +0 -153
  34. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  35. data/benchmarks/benchmark_runner.rb +0 -70
  36. data/benchmarks/benchmark_serialization.rb +0 -180
  37. data/benchmarks/benchmark_shorthand.rb +0 -109
  38. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  39. data/benchmarks/benchmark_specificity.rb +0 -124
  40. data/benchmarks/benchmark_string_allocation.rb +0 -151
  41. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  42. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  43. data/benchmarks/benchmark_value_splitter.rb +0 -54
  44. data/benchmarks/benchmark_yjit.rb +0 -158
  45. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  46. data/benchmarks/profile_to_s.rb +0 -23
  47. data/benchmarks/speedup_calculator.rb +0 -83
  48. data/benchmarks/system_metadata.rb +0 -81
  49. data/benchmarks/templates/benchmarks.md.erb +0 -221
  50. data/benchmarks/yjit_tests.rb +0 -141
  51. data/scripts/fuzzer/run.rb +0 -828
  52. data/scripts/fuzzer/worker.rb +0 -99
  53. data/scripts/generate_benchmarks_md.rb +0 -155
@@ -0,0 +1,1146 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure Ruby CSS merge implementation
4
+ # NO REGEXP ALLOWED - use string manipulation only
5
+
6
+ module Cataract
7
+ module Merge
8
+ # Property name constants (US-ASCII for merge output)
9
+ PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
10
+ PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
11
+ PROP_MARGIN_RIGHT = 'margin-right'.encode(Encoding::US_ASCII).freeze
12
+ PROP_MARGIN_BOTTOM = 'margin-bottom'.encode(Encoding::US_ASCII).freeze
13
+ PROP_MARGIN_LEFT = 'margin-left'.encode(Encoding::US_ASCII).freeze
14
+
15
+ PROP_PADDING = 'padding'.encode(Encoding::US_ASCII).freeze
16
+ PROP_PADDING_TOP = 'padding-top'.encode(Encoding::US_ASCII).freeze
17
+ PROP_PADDING_RIGHT = 'padding-right'.encode(Encoding::US_ASCII).freeze
18
+ PROP_PADDING_BOTTOM = 'padding-bottom'.encode(Encoding::US_ASCII).freeze
19
+ PROP_PADDING_LEFT = 'padding-left'.encode(Encoding::US_ASCII).freeze
20
+
21
+ PROP_BORDER = 'border'.encode(Encoding::US_ASCII).freeze
22
+ PROP_BORDER_WIDTH = 'border-width'.encode(Encoding::US_ASCII).freeze
23
+ PROP_BORDER_STYLE = 'border-style'.encode(Encoding::US_ASCII).freeze
24
+ PROP_BORDER_COLOR = 'border-color'.encode(Encoding::US_ASCII).freeze
25
+
26
+ PROP_BORDER_TOP = 'border-top'.encode(Encoding::US_ASCII).freeze
27
+ PROP_BORDER_RIGHT = 'border-right'.encode(Encoding::US_ASCII).freeze
28
+ PROP_BORDER_BOTTOM = 'border-bottom'.encode(Encoding::US_ASCII).freeze
29
+ PROP_BORDER_LEFT = 'border-left'.encode(Encoding::US_ASCII).freeze
30
+
31
+ PROP_BORDER_TOP_WIDTH = 'border-top-width'.encode(Encoding::US_ASCII).freeze
32
+ PROP_BORDER_RIGHT_WIDTH = 'border-right-width'.encode(Encoding::US_ASCII).freeze
33
+ PROP_BORDER_BOTTOM_WIDTH = 'border-bottom-width'.encode(Encoding::US_ASCII).freeze
34
+ PROP_BORDER_LEFT_WIDTH = 'border-left-width'.encode(Encoding::US_ASCII).freeze
35
+
36
+ PROP_BORDER_TOP_STYLE = 'border-top-style'.encode(Encoding::US_ASCII).freeze
37
+ PROP_BORDER_RIGHT_STYLE = 'border-right-style'.encode(Encoding::US_ASCII).freeze
38
+ PROP_BORDER_BOTTOM_STYLE = 'border-bottom-style'.encode(Encoding::US_ASCII).freeze
39
+ PROP_BORDER_LEFT_STYLE = 'border-left-style'.encode(Encoding::US_ASCII).freeze
40
+
41
+ PROP_BORDER_TOP_COLOR = 'border-top-color'.encode(Encoding::US_ASCII).freeze
42
+ PROP_BORDER_RIGHT_COLOR = 'border-right-color'.encode(Encoding::US_ASCII).freeze
43
+ PROP_BORDER_BOTTOM_COLOR = 'border-bottom-color'.encode(Encoding::US_ASCII).freeze
44
+ PROP_BORDER_LEFT_COLOR = 'border-left-color'.encode(Encoding::US_ASCII).freeze
45
+
46
+ PROP_FONT = 'font'.encode(Encoding::US_ASCII).freeze
47
+ PROP_FONT_STYLE = 'font-style'.encode(Encoding::US_ASCII).freeze
48
+ PROP_FONT_VARIANT = 'font-variant'.encode(Encoding::US_ASCII).freeze
49
+ PROP_FONT_WEIGHT = 'font-weight'.encode(Encoding::US_ASCII).freeze
50
+ PROP_FONT_SIZE = 'font-size'.encode(Encoding::US_ASCII).freeze
51
+ PROP_LINE_HEIGHT = 'line-height'.encode(Encoding::US_ASCII).freeze
52
+ PROP_FONT_FAMILY = 'font-family'.encode(Encoding::US_ASCII).freeze
53
+
54
+ PROP_BACKGROUND = 'background'.encode(Encoding::US_ASCII).freeze
55
+ PROP_BACKGROUND_COLOR = 'background-color'.encode(Encoding::US_ASCII).freeze
56
+ PROP_BACKGROUND_IMAGE = 'background-image'.encode(Encoding::US_ASCII).freeze
57
+ PROP_BACKGROUND_REPEAT = 'background-repeat'.encode(Encoding::US_ASCII).freeze
58
+ PROP_BACKGROUND_ATTACHMENT = 'background-attachment'.encode(Encoding::US_ASCII).freeze
59
+ PROP_BACKGROUND_POSITION = 'background-position'.encode(Encoding::US_ASCII).freeze
60
+
61
+ PROP_LIST_STYLE = 'list-style'.encode(Encoding::US_ASCII).freeze
62
+ PROP_LIST_STYLE_TYPE = 'list-style-type'.encode(Encoding::US_ASCII).freeze
63
+ PROP_LIST_STYLE_POSITION = 'list-style-position'.encode(Encoding::US_ASCII).freeze
64
+ PROP_LIST_STYLE_IMAGE = 'list-style-image'.encode(Encoding::US_ASCII).freeze
65
+
66
+ # Shorthand property families
67
+ MARGIN_SIDES = [PROP_MARGIN_TOP, PROP_MARGIN_RIGHT, PROP_MARGIN_BOTTOM, PROP_MARGIN_LEFT].freeze
68
+ PADDING_SIDES = [PROP_PADDING_TOP, PROP_PADDING_RIGHT, PROP_PADDING_BOTTOM, PROP_PADDING_LEFT].freeze
69
+
70
+ BORDER_WIDTHS = [
71
+ PROP_BORDER_TOP_WIDTH,
72
+ PROP_BORDER_RIGHT_WIDTH,
73
+ PROP_BORDER_BOTTOM_WIDTH,
74
+ PROP_BORDER_LEFT_WIDTH
75
+ ].freeze
76
+
77
+ BORDER_STYLES = [
78
+ PROP_BORDER_TOP_STYLE,
79
+ PROP_BORDER_RIGHT_STYLE,
80
+ PROP_BORDER_BOTTOM_STYLE,
81
+ PROP_BORDER_LEFT_STYLE
82
+ ].freeze
83
+
84
+ BORDER_COLORS = [
85
+ PROP_BORDER_TOP_COLOR,
86
+ PROP_BORDER_RIGHT_COLOR,
87
+ PROP_BORDER_BOTTOM_COLOR,
88
+ PROP_BORDER_LEFT_COLOR
89
+ ].freeze
90
+
91
+ # Side name constants (for string operations, not CSS properties)
92
+ SIDE_TOP = 'top'
93
+ SIDE_RIGHT = 'right'
94
+ SIDE_BOTTOM = 'bottom'
95
+ SIDE_LEFT = 'left'
96
+ BORDER_SIDES = [SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM, SIDE_LEFT].freeze
97
+
98
+ FONT_PROPERTIES = [
99
+ PROP_FONT_STYLE,
100
+ PROP_FONT_VARIANT,
101
+ PROP_FONT_WEIGHT,
102
+ PROP_FONT_SIZE,
103
+ PROP_LINE_HEIGHT,
104
+ PROP_FONT_FAMILY
105
+ ].freeze
106
+
107
+ BACKGROUND_PROPERTIES = [
108
+ PROP_BACKGROUND_COLOR,
109
+ PROP_BACKGROUND_IMAGE,
110
+ PROP_BACKGROUND_REPEAT,
111
+ PROP_BACKGROUND_POSITION,
112
+ PROP_BACKGROUND_ATTACHMENT
113
+ ].freeze
114
+
115
+ LIST_STYLE_PROPERTIES = [PROP_LIST_STYLE_TYPE, PROP_LIST_STYLE_POSITION, PROP_LIST_STYLE_IMAGE].freeze
116
+ BORDER_ALL = (BORDER_WIDTHS + BORDER_STYLES + BORDER_COLORS).freeze
117
+
118
+ # List style keywords
119
+ LIST_STYLE_POSITION_KEYWORDS = %w[inside outside].freeze
120
+
121
+ # Border property keywords
122
+ BORDER_WIDTH_KEYWORDS = %w[thin medium thick].freeze
123
+ BORDER_STYLE_KEYWORDS = %w[none hidden dotted dashed solid double groove ridge inset outset].freeze
124
+
125
+ # Font property keywords
126
+ FONT_SIZE_KEYWORDS = %w[xx-small x-small small medium large x-large xx-large smaller larger].freeze
127
+ FONT_STYLE_KEYWORDS = %w[normal italic oblique].freeze
128
+ FONT_VARIANT_KEYWORDS = %w[normal small-caps].freeze
129
+ FONT_WEIGHT_KEYWORDS = %w[normal bold bolder lighter 100 200 300 400 500 600 700 800 900].freeze
130
+
131
+ # Background property keywords
132
+ BACKGROUND_REPEAT_KEYWORDS = %w[repeat repeat-x repeat-y no-repeat space round].freeze
133
+ BACKGROUND_ATTACHMENT_KEYWORDS = %w[scroll fixed local].freeze
134
+ BACKGROUND_POSITION_KEYWORDS = %w[left right center top bottom].freeze
135
+
136
+ # Merge stylesheet according to CSS cascade rules
137
+ #
138
+ # @param stylesheet [Stylesheet] Stylesheet to merge
139
+ # @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
140
+ # @return [Stylesheet] Merged stylesheet
141
+ def self.merge(stylesheet, mutate: false)
142
+ # Separate AtRules (pass-through) from regular Rules (to merge)
143
+ at_rules = []
144
+ regular_rules = []
145
+
146
+ stylesheet.rules.each do |rule|
147
+ if rule.at_rule?
148
+ at_rules << rule
149
+ else
150
+ regular_rules << rule
151
+ end
152
+ end
153
+
154
+ # Expand shorthands in regular rules only (AtRules don't have declarations)
155
+ regular_rules.each { |rule| expand_shorthands!(rule) }
156
+
157
+ merged_rules = []
158
+
159
+ # Always group by selector and preserve original selectors
160
+ # (Nesting is flattened during parsing, so we just merge by resolved selector)
161
+ grouped = regular_rules.group_by(&:selector)
162
+ grouped.each do |selector, rules|
163
+ merged_rule = merge_rules_for_selector(selector, rules)
164
+ merged_rules << merged_rule if merged_rule
165
+ end
166
+
167
+ # Recreate shorthands where possible
168
+ merged_rules.each { |rule| recreate_shorthands!(rule) }
169
+
170
+ # Add passthrough AtRules to output
171
+ merged_rules.concat(at_rules)
172
+
173
+ # Create result stylesheet
174
+ if mutate
175
+ stylesheet.instance_variable_set(:@rules, merged_rules)
176
+ # Update rule IDs
177
+ merged_rules.each_with_index { |rule, i| rule.id = i }
178
+ # Clear media index (no media rules after merge flattens everything)
179
+ stylesheet.instance_variable_set(:@media_index, {})
180
+ stylesheet
181
+ else
182
+ # Create new Stylesheet with merged rules
183
+ result = Stylesheet.new
184
+ result.instance_variable_set(:@rules, merged_rules)
185
+ result.instance_variable_set(:@media_index, {})
186
+ result.instance_variable_set(:@charset, stylesheet.charset)
187
+ # Update rule IDs
188
+ merged_rules.each_with_index { |rule, i| rule.id = i }
189
+ result
190
+ end
191
+ end
192
+
193
+ # Merge multiple rules with same selector
194
+ #
195
+ # @param selector [String] The selector
196
+ # @param rules [Array<Rule>] Rules with this selector
197
+ # @return [Rule] Merged rule with cascaded declarations
198
+ def self.merge_rules_for_selector(selector, rules)
199
+ # Build declaration map: property => [source_order, specificity, important, value]
200
+ decl_map = {}
201
+
202
+ rules.each do |rule|
203
+ spec = rule.specificity || calculate_specificity(rule.selector)
204
+
205
+ rule.declarations.each_with_index do |decl, idx|
206
+ # Property is already US-ASCII and lowercase from parser
207
+ prop = decl.property
208
+
209
+ # Calculate source order (higher = later)
210
+ source_order = rule.id * 1000 + idx
211
+
212
+ existing = decl_map[prop]
213
+
214
+ # Apply cascade rules:
215
+ # 1. !important always wins over non-important
216
+ # 2. Higher specificity wins
217
+ # 3. Later source order wins (if specificity and importance are equal)
218
+
219
+ if existing.nil?
220
+ decl_map[prop] = [source_order, spec, decl.important, decl.value]
221
+ else
222
+ existing_order, existing_spec, existing_important, _existing_val = existing
223
+
224
+ # Determine winner
225
+ should_replace = false
226
+
227
+ if decl.important && !existing_important
228
+ # New is important, existing is not -> new wins
229
+ should_replace = true
230
+ elsif !decl.important && existing_important
231
+ # Existing is important, new is not -> existing wins
232
+ should_replace = false
233
+ elsif spec > existing_spec
234
+ # Higher specificity wins
235
+ should_replace = true
236
+ elsif spec < existing_spec
237
+ # Lower specificity loses
238
+ should_replace = false
239
+ else
240
+ # Same specificity and importance -> later source order wins
241
+ should_replace = source_order > existing_order
242
+ end
243
+
244
+ if should_replace
245
+ decl_map[prop] = [source_order, spec, decl.important, decl.value]
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ # Build final declarations array
252
+ # NOTE: Using each with << instead of map for performance (1.05-1.11x faster)
253
+ # The << pattern is faster than map's implicit array return (even without YJIT)
254
+ #
255
+ # NOTE: We don't sort by source_order here because:
256
+ # 1. Hash iteration order in Ruby is insertion order (since Ruby 1.9)
257
+ # 2. Declaration order doesn't affect CSS behavior (cascade is already resolved)
258
+ # 3. Sorting would add overhead for purely aesthetic output
259
+ # The output order is roughly source order but may vary when properties are
260
+ # overridden by later rules with higher specificity or importance.
261
+ declarations = []
262
+ decl_map.each do |prop, (_order, _spec, important, value)|
263
+ declarations << Declaration.new(prop, value, important)
264
+ end
265
+
266
+ return nil if declarations.empty?
267
+
268
+ # Create merged rule
269
+ Rule.new(
270
+ 0, # ID will be updated later
271
+ selector,
272
+ declarations,
273
+ rules.first.specificity, # Use first rule's specificity
274
+ nil, # No parent after flattening
275
+ nil # No nesting style after flattening
276
+ )
277
+ end
278
+
279
+ # Calculate specificity for a selector
280
+ #
281
+ # @param selector [String] CSS selector
282
+ # @return [Integer] Specificity value
283
+ def self.calculate_specificity(selector)
284
+ Cataract.calculate_specificity(selector)
285
+ end
286
+
287
+ # Expand shorthand properties in a rule (mutates declarations)
288
+ #
289
+ # @param rule [Rule] Rule to expand
290
+ def self.expand_shorthands!(rule)
291
+ expanded = []
292
+
293
+ rule.declarations.each do |decl|
294
+ prop = decl.property
295
+
296
+ case prop
297
+ when 'margin'
298
+ expanded.concat(expand_margin(decl))
299
+ when 'padding'
300
+ expanded.concat(expand_padding(decl))
301
+ when 'border'
302
+ expanded.concat(expand_border(decl))
303
+ when 'border-top', 'border-right', 'border-bottom', 'border-left'
304
+ expanded.concat(expand_border_side(decl))
305
+ when 'border-width'
306
+ expanded.concat(expand_border_width(decl))
307
+ when 'border-style'
308
+ expanded.concat(expand_border_style(decl))
309
+ when 'border-color'
310
+ expanded.concat(expand_border_color(decl))
311
+ when 'font'
312
+ expanded.concat(expand_font(decl))
313
+ when 'background'
314
+ expanded.concat(expand_background(decl))
315
+ when 'list-style'
316
+ expanded.concat(expand_list_style(decl))
317
+ else
318
+ expanded << decl
319
+ end
320
+ end
321
+
322
+ rule.declarations.replace(expanded)
323
+ end
324
+
325
+ # Expand margin shorthand
326
+ def self.expand_margin(decl)
327
+ sides = parse_four_sides(decl.value)
328
+ [
329
+ Declaration.new(PROP_MARGIN_TOP, sides[0], decl.important),
330
+ Declaration.new(PROP_MARGIN_RIGHT, sides[1], decl.important),
331
+ Declaration.new(PROP_MARGIN_BOTTOM, sides[2], decl.important),
332
+ Declaration.new(PROP_MARGIN_LEFT, sides[3], decl.important)
333
+ ]
334
+ end
335
+
336
+ # Expand padding shorthand
337
+ def self.expand_padding(decl)
338
+ sides = parse_four_sides(decl.value)
339
+ [
340
+ Declaration.new(PROP_PADDING_TOP, sides[0], decl.important),
341
+ Declaration.new(PROP_PADDING_RIGHT, sides[1], decl.important),
342
+ Declaration.new(PROP_PADDING_BOTTOM, sides[2], decl.important),
343
+ Declaration.new(PROP_PADDING_LEFT, sides[3], decl.important)
344
+ ]
345
+ end
346
+
347
+ # Parse four-sided value (margin/padding)
348
+ # "10px" -> ["10px", "10px", "10px", "10px"]
349
+ # "10px 20px" -> ["10px", "20px", "10px", "20px"]
350
+ # "10px 20px 30px" -> ["10px", "20px", "30px", "20px"]
351
+ # "10px 20px 30px 40px" -> ["10px", "20px", "30px", "40px"]
352
+ def self.parse_four_sides(value)
353
+ parts = split_on_whitespace(value)
354
+
355
+ case parts.length
356
+ when 1
357
+ [parts[0], parts[0], parts[0], parts[0]]
358
+ when 2
359
+ [parts[0], parts[1], parts[0], parts[1]]
360
+ when 3
361
+ [parts[0], parts[1], parts[2], parts[1]]
362
+ else
363
+ [parts[0], parts[1], parts[2], parts[3]]
364
+ end
365
+ end
366
+
367
+ # Split value on whitespace (handling calc() and other functions)
368
+ def self.split_on_whitespace(value)
369
+ parts = []
370
+ current = String.new
371
+ paren_depth = 0
372
+
373
+ i = 0
374
+ len = value.bytesize
375
+ while i < len
376
+ byte = value.getbyte(i)
377
+
378
+ if byte == BYTE_LPAREN
379
+ paren_depth += 1
380
+ current << byte
381
+ elsif byte == BYTE_RPAREN
382
+ paren_depth -= 1
383
+ current << byte
384
+ elsif byte == BYTE_SPACE && paren_depth == 0
385
+ parts << current unless current.empty?
386
+ current = String.new
387
+ else
388
+ current << byte
389
+ end
390
+
391
+ i += 1
392
+ end
393
+
394
+ parts << current unless current.empty?
395
+ parts
396
+ end
397
+
398
+ # Expand border shorthand (e.g., "1px solid red")
399
+ def self.expand_border(decl)
400
+ # Parse border value
401
+ width, style, color = parse_border_value(decl.value)
402
+
403
+ result = []
404
+
405
+ # Expand to all sides using property constants
406
+ if width
407
+ result << Declaration.new(PROP_BORDER_TOP_WIDTH, width, decl.important)
408
+ result << Declaration.new(PROP_BORDER_RIGHT_WIDTH, width, decl.important)
409
+ result << Declaration.new(PROP_BORDER_BOTTOM_WIDTH, width, decl.important)
410
+ result << Declaration.new(PROP_BORDER_LEFT_WIDTH, width, decl.important)
411
+ end
412
+
413
+ if style
414
+ result << Declaration.new(PROP_BORDER_TOP_STYLE, style, decl.important)
415
+ result << Declaration.new(PROP_BORDER_RIGHT_STYLE, style, decl.important)
416
+ result << Declaration.new(PROP_BORDER_BOTTOM_STYLE, style, decl.important)
417
+ result << Declaration.new(PROP_BORDER_LEFT_STYLE, style, decl.important)
418
+ end
419
+
420
+ if color
421
+ result << Declaration.new(PROP_BORDER_TOP_COLOR, color, decl.important)
422
+ result << Declaration.new(PROP_BORDER_RIGHT_COLOR, color, decl.important)
423
+ result << Declaration.new(PROP_BORDER_BOTTOM_COLOR, color, decl.important)
424
+ result << Declaration.new(PROP_BORDER_LEFT_COLOR, color, decl.important)
425
+ end
426
+
427
+ result
428
+ end
429
+
430
+ # Expand border-side shorthand (e.g., "border-top: 1px solid red")
431
+ def self.expand_border_side(decl)
432
+ # Extract side from property name (e.g., "border-top" -> "top")
433
+ side = decl.property.byteslice(7..-1) # Skip "border-" prefix
434
+ width, style, color = parse_border_value(decl.value)
435
+
436
+ result = []
437
+
438
+ # Map side to property constants
439
+ if width
440
+ width_prop = case side
441
+ when SIDE_TOP then PROP_BORDER_TOP_WIDTH
442
+ when SIDE_RIGHT then PROP_BORDER_RIGHT_WIDTH
443
+ when SIDE_BOTTOM then PROP_BORDER_BOTTOM_WIDTH
444
+ when SIDE_LEFT then PROP_BORDER_LEFT_WIDTH
445
+ end
446
+ result << Declaration.new(width_prop, width, decl.important)
447
+ end
448
+
449
+ if style
450
+ style_prop = case side
451
+ when SIDE_TOP then PROP_BORDER_TOP_STYLE
452
+ when SIDE_RIGHT then PROP_BORDER_RIGHT_STYLE
453
+ when SIDE_BOTTOM then PROP_BORDER_BOTTOM_STYLE
454
+ when SIDE_LEFT then PROP_BORDER_LEFT_STYLE
455
+ end
456
+ result << Declaration.new(style_prop, style, decl.important)
457
+ end
458
+
459
+ if color
460
+ color_prop = case side
461
+ when SIDE_TOP then PROP_BORDER_TOP_COLOR
462
+ when SIDE_RIGHT then PROP_BORDER_RIGHT_COLOR
463
+ when SIDE_BOTTOM then PROP_BORDER_BOTTOM_COLOR
464
+ when SIDE_LEFT then PROP_BORDER_LEFT_COLOR
465
+ end
466
+ result << Declaration.new(color_prop, color, decl.important)
467
+ end
468
+
469
+ result
470
+ end
471
+
472
+ # Expand border-width shorthand
473
+ def self.expand_border_width(decl)
474
+ sides = parse_four_sides(decl.value)
475
+ [
476
+ Declaration.new(PROP_BORDER_TOP_WIDTH, sides[0], decl.important),
477
+ Declaration.new(PROP_BORDER_RIGHT_WIDTH, sides[1], decl.important),
478
+ Declaration.new(PROP_BORDER_BOTTOM_WIDTH, sides[2], decl.important),
479
+ Declaration.new(PROP_BORDER_LEFT_WIDTH, sides[3], decl.important)
480
+ ]
481
+ end
482
+
483
+ # Expand border-style shorthand
484
+ def self.expand_border_style(decl)
485
+ sides = parse_four_sides(decl.value)
486
+ [
487
+ Declaration.new(PROP_BORDER_TOP_STYLE, sides[0], decl.important),
488
+ Declaration.new(PROP_BORDER_RIGHT_STYLE, sides[1], decl.important),
489
+ Declaration.new(PROP_BORDER_BOTTOM_STYLE, sides[2], decl.important),
490
+ Declaration.new(PROP_BORDER_LEFT_STYLE, sides[3], decl.important)
491
+ ]
492
+ end
493
+
494
+ # Expand border-color shorthand
495
+ def self.expand_border_color(decl)
496
+ sides = parse_four_sides(decl.value)
497
+ [
498
+ Declaration.new(PROP_BORDER_TOP_COLOR, sides[0], decl.important),
499
+ Declaration.new(PROP_BORDER_RIGHT_COLOR, sides[1], decl.important),
500
+ Declaration.new(PROP_BORDER_BOTTOM_COLOR, sides[2], decl.important),
501
+ Declaration.new(PROP_BORDER_LEFT_COLOR, sides[3], decl.important)
502
+ ]
503
+ end
504
+
505
+ # Parse border value (e.g., "1px solid red" -> ["1px", "solid", "red"])
506
+ def self.parse_border_value(value)
507
+ parts = split_on_whitespace(value)
508
+ width = nil
509
+ style = nil
510
+ color = nil
511
+
512
+ # Identify each part by type
513
+ parts.each do |part|
514
+ if is_border_width?(part)
515
+ width = part
516
+ elsif is_border_style?(part)
517
+ style = part
518
+ else
519
+ color = part # Assume color if not width or style
520
+ end
521
+ end
522
+
523
+ [width, style, color]
524
+ end
525
+
526
+ # Check if value looks like a border width
527
+ def self.is_border_width?(value)
528
+ # Check for numeric values or width keywords
529
+ return true if BORDER_WIDTH_KEYWORDS.include?(value)
530
+
531
+ # Check if value contains a digit (byte-by-byte)
532
+ i = 0
533
+ len = value.bytesize
534
+ while i < len
535
+ byte = value.getbyte(i)
536
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
537
+
538
+ i += 1
539
+ end
540
+
541
+ false
542
+ end
543
+
544
+ # Check if value is a border style
545
+ def self.is_border_style?(value)
546
+ BORDER_STYLE_KEYWORDS.include?(value)
547
+ end
548
+
549
+ # Expand font shorthand
550
+ # Format: [style] [variant] [weight] size[/line-height] family
551
+ def self.expand_font(decl)
552
+ value = decl.value
553
+ parts = split_on_whitespace(value)
554
+
555
+ # Need at least 2 parts (size and family)
556
+ return [decl] if parts.length < 2
557
+
558
+ # Parse from left to right
559
+ # Optional: style, variant, weight (can appear in any order)
560
+ # Required: size (with optional /line-height), family
561
+
562
+ i = 0
563
+ style = nil
564
+ variant = nil
565
+ weight = nil
566
+ size = nil
567
+ line_height = nil
568
+ family_parts = []
569
+
570
+ # Process optional style/variant/weight
571
+ while i < parts.length - 1 # Leave at least 1 for family
572
+ part = parts[i]
573
+
574
+ # Check if this could be size (has digit or is a size keyword)
575
+ if is_font_size?(part)
576
+ # This is the size, rest is family
577
+ size_part = part
578
+
579
+ # Check for line-height (find '/' byte)
580
+ slash_idx = nil
581
+ j = 0
582
+ len = size_part.bytesize
583
+ while j < len
584
+ if size_part.getbyte(j) == BYTE_SLASH_FWD
585
+ slash_idx = j
586
+ break
587
+ end
588
+ j += 1
589
+ end
590
+
591
+ if slash_idx
592
+ size = size_part.byteslice(0, slash_idx)
593
+ line_height = size_part.byteslice((slash_idx + 1)..-1)
594
+ else
595
+ size = size_part
596
+ end
597
+
598
+ # Rest is family
599
+ family_parts = parts[(i + 1)..-1]
600
+ break
601
+ elsif is_font_style?(part)
602
+ style = part
603
+ elsif is_font_variant?(part)
604
+ variant = part
605
+ elsif is_font_weight?(part)
606
+ weight = part
607
+ else
608
+ # Unknown, might be start of family - treat everything from here as family
609
+ family_parts = parts[i..-1]
610
+ break
611
+ end
612
+
613
+ i += 1
614
+ end
615
+
616
+ family = family_parts.join(' ')
617
+
618
+ # Font shorthand sets ALL longhand properties
619
+ # Unspecified values get CSS initial values
620
+ # Size and family are required; if missing, return unexpanded
621
+ return [decl] if !size || family.empty?
622
+
623
+ result = []
624
+ result << Declaration.new(PROP_FONT_STYLE, style || 'normal', decl.important)
625
+ result << Declaration.new(PROP_FONT_VARIANT, variant || 'normal', decl.important)
626
+ result << Declaration.new(PROP_FONT_WEIGHT, weight || 'normal', decl.important)
627
+ result << Declaration.new(PROP_FONT_SIZE, size, decl.important)
628
+ result << Declaration.new(PROP_LINE_HEIGHT, line_height || 'normal', decl.important)
629
+ result << Declaration.new(PROP_FONT_FAMILY, family, decl.important)
630
+
631
+ result
632
+ end
633
+
634
+ # Check if value is a font size
635
+ def self.is_font_size?(value)
636
+ # Has digit or is a keyword
637
+ i = 0
638
+ len = value.bytesize
639
+ while i < len
640
+ byte = value.getbyte(i)
641
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
642
+
643
+ i += 1
644
+ end
645
+ FONT_SIZE_KEYWORDS.include?(value)
646
+ end
647
+
648
+ # Check if value is a font style
649
+ def self.is_font_style?(value)
650
+ FONT_STYLE_KEYWORDS.include?(value)
651
+ end
652
+
653
+ # Check if value is a font variant
654
+ def self.is_font_variant?(value)
655
+ FONT_VARIANT_KEYWORDS.include?(value)
656
+ end
657
+
658
+ # Check if value is a font weight
659
+ def self.is_font_weight?(value)
660
+ # Check for numeric weights like 400, 700
661
+ i = 0
662
+ len = value.bytesize
663
+ while i < len
664
+ byte = value.getbyte(i)
665
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
666
+
667
+ i += 1
668
+ end
669
+ FONT_WEIGHT_KEYWORDS.include?(value)
670
+ end
671
+
672
+ # Expand background shorthand
673
+ # Format: [color] [image] [repeat] [attachment] [position]
674
+ def self.expand_background(decl)
675
+ value = decl.value
676
+ parts = split_on_whitespace(value)
677
+
678
+ return [decl] if parts.empty?
679
+
680
+ # Parse background components (simple heuristic)
681
+ # According to CSS spec, background shorthand sets ALL properties
682
+ # Any unspecified values get their initial values
683
+ color = nil
684
+ image = nil
685
+ repeat = nil
686
+ attachment = nil
687
+ position = nil
688
+
689
+ parts.each do |part|
690
+ if starts_with_url?(part) || part == 'none'
691
+ image = part
692
+ elsif BACKGROUND_REPEAT_KEYWORDS.include?(part)
693
+ repeat = part
694
+ elsif BACKGROUND_ATTACHMENT_KEYWORDS.include?(part)
695
+ attachment = part
696
+ elsif is_position_value?(part)
697
+ position ||= String.new
698
+ position << ' ' unless position.empty?
699
+ position << part
700
+ else
701
+ # Assume it's a color
702
+ color = part
703
+ end
704
+ end
705
+
706
+ # Background shorthand sets ALL longhand properties
707
+ # Unspecified values get CSS initial values
708
+ result = []
709
+ result << Declaration.new(PROP_BACKGROUND_COLOR, color || 'transparent', decl.important)
710
+ result << Declaration.new(PROP_BACKGROUND_IMAGE, image || 'none', decl.important)
711
+ result << Declaration.new(PROP_BACKGROUND_REPEAT, repeat || 'repeat', decl.important)
712
+ result << Declaration.new(PROP_BACKGROUND_ATTACHMENT, attachment || 'scroll', decl.important)
713
+ result << Declaration.new(PROP_BACKGROUND_POSITION, position || '0% 0%', decl.important)
714
+
715
+ result
716
+ end
717
+
718
+ # Check if value starts with 'url('
719
+ def self.starts_with_url?(value)
720
+ return false if value.bytesize < 4
721
+
722
+ value.getbyte(0) == BYTE_LOWER_U &&
723
+ value.getbyte(1) == BYTE_LOWER_R &&
724
+ value.getbyte(2) == BYTE_LOWER_L &&
725
+ value.getbyte(3) == BYTE_LPAREN
726
+ end
727
+
728
+ # Check if value is a position value (for background-position)
729
+ def self.is_position_value?(value)
730
+ return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
731
+
732
+ # Check for '%' or digits
733
+ i = 0
734
+ len = value.bytesize
735
+ while i < len
736
+ byte = value.getbyte(i)
737
+ return true if byte == BYTE_PERCENT
738
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
739
+
740
+ i += 1
741
+ end
742
+ false
743
+ end
744
+
745
+ # Expand list-style shorthand
746
+ # Format: [type] [position] [image]
747
+ def self.expand_list_style(decl)
748
+ value = decl.value
749
+ parts = split_on_whitespace(value)
750
+
751
+ return [decl] if parts.empty?
752
+
753
+ result = []
754
+ type = nil
755
+ position = nil
756
+ image = nil
757
+
758
+ parts.each do |part|
759
+ if starts_with_url?(part) || part == 'none'
760
+ image = part
761
+ elsif LIST_STYLE_POSITION_KEYWORDS.include?(part)
762
+ position = part
763
+ else
764
+ # Assume it's a type (disc, circle, square, etc.)
765
+ type = part
766
+ end
767
+ end
768
+
769
+ result << Declaration.new(PROP_LIST_STYLE_TYPE, type, decl.important) if type
770
+ result << Declaration.new(PROP_LIST_STYLE_POSITION, position, decl.important) if position
771
+ result << Declaration.new(PROP_LIST_STYLE_IMAGE, image, decl.important) if image
772
+
773
+ result.empty? ? [decl] : result
774
+ end
775
+
776
+ # Recreate shorthand properties where possible (mutates declarations)
777
+ #
778
+ # @param rule [Rule] Rule to recreate shorthands in
779
+ def self.recreate_shorthands!(rule)
780
+ # Build property map
781
+ prop_map = {}
782
+ rule.declarations.each { |d| prop_map[d.property] = d }
783
+
784
+ # Try to recreate margin
785
+ recreate_margin!(rule, prop_map)
786
+
787
+ # Try to recreate padding
788
+ recreate_padding!(rule, prop_map)
789
+
790
+ # Try to recreate border
791
+ recreate_border!(rule, prop_map)
792
+
793
+ # Try to recreate list-style
794
+ recreate_list_style!(rule, prop_map)
795
+
796
+ # Try to recreate font
797
+ recreate_font!(rule, prop_map)
798
+
799
+ # Try to recreate background
800
+ recreate_background!(rule, prop_map)
801
+ end
802
+
803
+ # Try to recreate margin shorthand
804
+ def self.recreate_margin!(rule, prop_map)
805
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
806
+ sides = []
807
+ MARGIN_SIDES.each { |s| sides << prop_map[s] }
808
+ return unless sides.all? # Need all four sides
809
+
810
+ # Check if all have same importance
811
+ # Use each + << instead of map
812
+ importances = []
813
+ sides.each { |s| importances << s.important }
814
+ importances.uniq!
815
+ return if importances.length > 1 # Mixed importance, can't create shorthand
816
+
817
+ # Use each + << instead of map
818
+ values = []
819
+ sides.each { |s| values << s.value }
820
+ important = sides.first.important
821
+
822
+ # Create optimized shorthand
823
+ shorthand_value = optimize_four_sides(values)
824
+
825
+ # Remove individual sides and append shorthand
826
+ # Note: We append rather than insert at original position to match C implementation behavior
827
+ rule.declarations.reject! { |d| MARGIN_SIDES.include?(d.property) }
828
+ rule.declarations << Declaration.new(PROP_MARGIN, shorthand_value, important)
829
+ end
830
+
831
+ # Try to recreate padding shorthand
832
+ def self.recreate_padding!(rule, prop_map)
833
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
834
+ sides = []
835
+ PADDING_SIDES.each { |s| sides << prop_map[s] }
836
+ return unless sides.all?
837
+
838
+ # Use each + << instead of map
839
+ importances = []
840
+ sides.each { |s| importances << s.important }
841
+ importances.uniq!
842
+ return if importances.length > 1
843
+
844
+ # Use each + << instead of map
845
+ values = []
846
+ sides.each { |s| values << s.value }
847
+ important = sides.first.important
848
+
849
+ shorthand_value = optimize_four_sides(values)
850
+
851
+ # Remove individual sides and append shorthand
852
+ # Note: We append rather than insert at original position to match C implementation behavior
853
+ rule.declarations.reject! { |d| PADDING_SIDES.include?(d.property) }
854
+ rule.declarations << Declaration.new(PROP_PADDING, shorthand_value, important)
855
+ end
856
+
857
+ # Helper: Check if all declarations have same value and importance
858
+ # Does single pass instead of multiple .map calls
859
+ def self.check_all_same?(decls)
860
+ return false if decls.empty?
861
+
862
+ first_val = decls[0].value
863
+ first_imp = decls[0].important
864
+
865
+ i = 1
866
+ while i < decls.length
867
+ return false if decls[i].value != first_val
868
+ return false if decls[i].important != first_imp
869
+
870
+ i += 1
871
+ end
872
+
873
+ true
874
+ end
875
+
876
+ # Try to recreate border shorthand
877
+ def self.recreate_border!(rule, prop_map)
878
+ # Check if we have all width/style/color properties with same values for all sides
879
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
880
+ widths = []
881
+ BORDER_WIDTHS.each { |p| widths << prop_map[p] }
882
+ styles = []
883
+ BORDER_STYLES.each { |p| styles << prop_map[p] }
884
+ colors = []
885
+ BORDER_COLORS.each { |p| colors << prop_map[p] }
886
+
887
+ # Check if all sides have same values and can create full border shorthand
888
+ # Check cheapest condition first (.all?), then do single pass for values/importance
889
+ widths_all_same = widths.all? && check_all_same?(widths)
890
+ styles_all_same = styles.all? && check_all_same?(styles)
891
+ colors_all_same = colors.all? && check_all_same?(colors)
892
+
893
+ # Can create FULL border shorthand ONLY if style is present (style is required for border shorthand)
894
+ # AND all properties that are present have same values and importance
895
+ if styles_all_same
896
+ # Check if we have width and/or color with same importance as style
897
+ can_create_border = true
898
+ important = styles.first.important
899
+
900
+ # If width is present, must be all-same and same importance
901
+ if widths.all?
902
+ can_create_border = false unless widths_all_same && widths.first.important == important
903
+ end
904
+
905
+ # If color is present, must be all-same and same importance
906
+ if colors.all?
907
+ can_create_border = false unless colors_all_same && colors.first.important == important
908
+ end
909
+
910
+ if can_create_border
911
+ # Create full border shorthand
912
+ parts = []
913
+ parts << widths.first.value if widths_all_same
914
+ parts << styles.first.value
915
+ parts << colors.first.value if colors_all_same
916
+
917
+ border_value = parts.join(' ')
918
+
919
+ # Remove individual properties and append shorthand
920
+ # Note: We append rather than insert at original position to match C implementation behavior
921
+ rule.declarations.reject! { |d| BORDER_ALL.include?(d.property) }
922
+ rule.declarations << Declaration.new(PROP_BORDER, border_value, important)
923
+ return
924
+ end
925
+ end
926
+
927
+ # Try to create border-width/style/color shorthands
928
+ recreate_border_width!(rule, widths) if widths.all?
929
+ recreate_border_style!(rule, styles) if styles.all?
930
+ recreate_border_color!(rule, colors) if colors.all?
931
+ end
932
+
933
+ # Recreate border-width shorthand
934
+ def self.recreate_border_width!(rule, widths)
935
+ importances = widths.map(&:important).uniq
936
+ return if importances.length > 1
937
+
938
+ values = widths.map(&:value)
939
+ important = widths.first.important
940
+
941
+ shorthand_value = optimize_four_sides(values)
942
+
943
+ rule.declarations.reject! { |d| BORDER_WIDTHS.include?(d.property) }
944
+ rule.declarations << Declaration.new(PROP_BORDER_WIDTH, shorthand_value, important)
945
+ end
946
+
947
+ # Recreate border-style shorthand
948
+ def self.recreate_border_style!(rule, styles)
949
+ importances = styles.map(&:important).uniq
950
+ return if importances.length > 1
951
+
952
+ values = styles.map(&:value)
953
+ important = styles.first.important
954
+
955
+ shorthand_value = optimize_four_sides(values)
956
+
957
+ rule.declarations.reject! { |d| BORDER_STYLES.include?(d.property) }
958
+ rule.declarations << Declaration.new(PROP_BORDER_STYLE, shorthand_value, important)
959
+ end
960
+
961
+ # Recreate border-color shorthand
962
+ def self.recreate_border_color!(rule, colors)
963
+ importances = colors.map(&:important).uniq
964
+ return if importances.length > 1
965
+
966
+ values = colors.map(&:value)
967
+ important = colors.first.important
968
+
969
+ shorthand_value = optimize_four_sides(values)
970
+
971
+ rule.declarations.reject! { |d| BORDER_COLORS.include?(d.property) }
972
+ rule.declarations << Declaration.new(PROP_BORDER_COLOR, shorthand_value, important)
973
+ end
974
+
975
+ # Optimize four-sided value representation
976
+ # ["10px", "10px", "10px", "10px"] -> "10px"
977
+ # ["10px", "20px", "10px", "20px"] -> "10px 20px"
978
+ # ["10px", "20px", "30px", "20px"] -> "10px 20px 30px"
979
+ # ["10px", "20px", "30px", "40px"] -> "10px 20px 30px 40px"
980
+ def self.optimize_four_sides(values)
981
+ top, right, bottom, left = values
982
+
983
+ if top == right && right == bottom && bottom == left
984
+ top
985
+ elsif top == bottom && right == left
986
+ "#{top} #{right}"
987
+ elsif right == left
988
+ "#{top} #{right} #{bottom}"
989
+ else
990
+ "#{top} #{right} #{bottom} #{left}"
991
+ end
992
+ end
993
+
994
+ # Try to recreate font shorthand
995
+ # Requires: font-size and font-family (minimum)
996
+ # Optional: font-style, font-variant, font-weight, line-height
997
+ def self.recreate_font!(rule, prop_map)
998
+ size = prop_map[PROP_FONT_SIZE]
999
+ family = prop_map[PROP_FONT_FAMILY]
1000
+
1001
+ # Need at least size and family
1002
+ return unless size && family
1003
+
1004
+ # Check if all font properties have same importance
1005
+ font_decls = FONT_PROPERTIES.filter_map { |p| prop_map[p] }
1006
+ return if font_decls.empty?
1007
+
1008
+ importances = font_decls.map(&:important).uniq
1009
+ return if importances.length > 1
1010
+
1011
+ important = font_decls.first.important
1012
+
1013
+ # Build font shorthand value
1014
+ # Strategy: Only omit defaults if we have ALL 6 properties (from shorthand expansion)
1015
+ # If we have a partial set, include all non-nil values
1016
+ style = prop_map[PROP_FONT_STYLE]&.value
1017
+ variant = prop_map[PROP_FONT_VARIANT]&.value
1018
+ weight = prop_map[PROP_FONT_WEIGHT]&.value
1019
+ line_height = prop_map[PROP_LINE_HEIGHT]&.value
1020
+
1021
+ all_present = style && variant && weight && line_height
1022
+ parts = []
1023
+
1024
+ if all_present
1025
+ # All properties present (likely from shorthand expansion) - omit defaults
1026
+ parts << style if style != 'normal'
1027
+ parts << variant if variant != 'normal'
1028
+ parts << weight if weight != 'normal'
1029
+ else
1030
+ # Partial set - include all non-nil values
1031
+ parts << style if style
1032
+ parts << variant if variant
1033
+ parts << weight if weight
1034
+ end
1035
+
1036
+ # Required: size[/line-height]
1037
+ if all_present
1038
+ # Omit line-height if default
1039
+ if line_height != 'normal'
1040
+ parts << "#{size.value}/#{line_height}"
1041
+ else
1042
+ parts << size.value
1043
+ end
1044
+ else
1045
+ # Include line-height if present
1046
+ if line_height
1047
+ parts << "#{size.value}/#{line_height}"
1048
+ else
1049
+ parts << size.value
1050
+ end
1051
+ end
1052
+
1053
+ # Required: family
1054
+ parts << family.value
1055
+
1056
+ shorthand_value = parts.join(' ')
1057
+
1058
+ # Remove individual properties and append shorthand
1059
+ # Note: We append rather than insert at original position to match C implementation behavior
1060
+ rule.declarations.reject! { |d| FONT_PROPERTIES.include?(d.property) }
1061
+ rule.declarations << Declaration.new(PROP_FONT, shorthand_value, important)
1062
+ end
1063
+
1064
+ # Try to recreate background shorthand
1065
+ # Can combine: background-color, background-image, background-repeat, etc.
1066
+ def self.recreate_background!(rule, prop_map)
1067
+ bg_props = BACKGROUND_PROPERTIES
1068
+ bg_decls = bg_props.filter_map { |p| prop_map[p] }
1069
+
1070
+ # Need at least 2 properties to create shorthand
1071
+ # Single properties should stay as longhands (e.g., background-color: blue)
1072
+ # because shorthand resets all other properties to initial values
1073
+ return if bg_decls.length < 2
1074
+
1075
+ # Check if all have same importance
1076
+ importances = bg_decls.map(&:important).uniq
1077
+ return if importances.length > 1
1078
+
1079
+ important = bg_decls.first.important
1080
+
1081
+ # Build background shorthand value
1082
+ # Strategy: Only omit defaults if we have ALL 5 properties (from shorthand expansion)
1083
+ # If we have a partial set (explicit longhands), include all values
1084
+ color = prop_map[PROP_BACKGROUND_COLOR]&.value
1085
+ image = prop_map[PROP_BACKGROUND_IMAGE]&.value
1086
+ repeat = prop_map[PROP_BACKGROUND_REPEAT]&.value
1087
+ position = prop_map[PROP_BACKGROUND_POSITION]&.value
1088
+ attachment = prop_map[PROP_BACKGROUND_ATTACHMENT]&.value
1089
+
1090
+ all_present = color && image && repeat && position && attachment
1091
+ parts = []
1092
+
1093
+ if all_present
1094
+ # All 5 properties present (likely from shorthand expansion) - omit defaults
1095
+ parts << color if color != 'transparent'
1096
+ parts << image if image != 'none'
1097
+ parts << repeat if repeat != 'repeat'
1098
+ parts << position if position != '0% 0%'
1099
+ parts << attachment if attachment != 'scroll'
1100
+ else
1101
+ # Partial set (explicit longhands) - include all non-nil values
1102
+ parts << color if color
1103
+ parts << image if image
1104
+ parts << repeat if repeat
1105
+ parts << position if position
1106
+ parts << attachment if attachment
1107
+ end
1108
+
1109
+ shorthand_value = parts.join(' ')
1110
+
1111
+ # Remove individual properties and append shorthand
1112
+ # Note: We append rather than insert at original position to match C implementation behavior
1113
+ rule.declarations.reject! { |d| BACKGROUND_PROPERTIES.include?(d.property) }
1114
+ rule.declarations << Declaration.new(PROP_BACKGROUND, shorthand_value, important)
1115
+ end
1116
+
1117
+ # Try to recreate list-style shorthand
1118
+ # Can combine: list-style-type, list-style-position, list-style-image
1119
+ def self.recreate_list_style!(rule, prop_map)
1120
+ ls_props = LIST_STYLE_PROPERTIES
1121
+ ls_decls = ls_props.filter_map { |p| prop_map[p] }
1122
+
1123
+ # Need at least 2 properties to create shorthand
1124
+ return if ls_decls.length < 2
1125
+
1126
+ # Check if all have same importance
1127
+ importances = ls_decls.map(&:important).uniq
1128
+ return if importances.length > 1
1129
+
1130
+ important = ls_decls.first.important
1131
+
1132
+ # Build list-style shorthand value
1133
+ parts = []
1134
+ parts << prop_map[PROP_LIST_STYLE_TYPE].value if prop_map[PROP_LIST_STYLE_TYPE]
1135
+ parts << prop_map[PROP_LIST_STYLE_POSITION].value if prop_map[PROP_LIST_STYLE_POSITION]
1136
+ parts << prop_map[PROP_LIST_STYLE_IMAGE].value if prop_map[PROP_LIST_STYLE_IMAGE]
1137
+
1138
+ shorthand_value = parts.join(' ')
1139
+
1140
+ # Remove individual properties and append shorthand
1141
+ # Note: We append rather than insert at original position to match C implementation behavior
1142
+ rule.declarations.reject! { |d| LIST_STYLE_PROPERTIES.include?(d.property) }
1143
+ rule.declarations << Declaration.new(PROP_LIST_STYLE, shorthand_value, important)
1144
+ end
1145
+ end
1146
+ end