cataract 0.1.3 → 0.2.0

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