cataract 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -6
  4. data/.rubocop_todo.yml +11 -19
  5. data/BENCHMARKS.md +40 -40
  6. data/CHANGELOG.md +28 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +312 -550
  11. data/ext/cataract/cataract.h +126 -16
  12. data/ext/cataract/css_parser.c +782 -806
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +26 -10
  18. data/lib/cataract/import_resolver.rb +36 -16
  19. data/lib/cataract/native.rb +30 -0
  20. data/lib/cataract/pure/byte_constants.rb +70 -67
  21. data/lib/cataract/pure/declarations.rb +125 -0
  22. data/lib/cataract/pure/flatten.rb +1195 -1182
  23. data/lib/cataract/pure/parser.rb +1721 -1652
  24. data/lib/cataract/pure/serializer.rb +575 -710
  25. data/lib/cataract/pure/specificity.rb +152 -175
  26. data/lib/cataract/pure.rb +73 -108
  27. data/lib/cataract/rule.rb +6 -3
  28. data/lib/cataract/stylesheet.rb +475 -415
  29. data/lib/cataract/version.rb +1 -1
  30. data/lib/cataract.rb +41 -30
  31. data/lib/tasks/profile.rake +6 -3
  32. metadata +4 -14
  33. data/ext/cataract/import_scanner.c +0 -174
  34. data/ext/cataract_old/cataract.c +0 -393
  35. data/ext/cataract_old/cataract.h +0 -250
  36. data/ext/cataract_old/css_parser.c +0 -933
  37. data/ext/cataract_old/extconf.rb +0 -67
  38. data/ext/cataract_old/import_scanner.c +0 -174
  39. data/ext/cataract_old/merge.c +0 -776
  40. data/ext/cataract_old/shorthand_expander.c +0 -902
  41. data/ext/cataract_old/specificity.c +0 -213
  42. data/ext/cataract_old/stylesheet.c +0 -290
  43. data/ext/cataract_old/value_splitter.c +0 -116
  44. data/lib/cataract/pure/helpers.rb +0 -35
@@ -1,796 +1,661 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Pure Ruby CSS parser - Serialization methods
3
+ # Pure Ruby CSS parser - Serialization
4
4
  # NO REGEXP ALLOWED - char-by-char parsing only
5
- #
6
- # @api private
7
- # This module contains internal serialization methods for converting parsed CSS
8
- # back to strings. These methods are called by Stylesheet#to_s and should not be
9
- # used directly. The public API is through the Stylesheet class.
10
5
 
11
6
  module Cataract
12
- # @api private
13
- # Helper: Build media query string from MediaQuery object or list
14
- # @param media_query [MediaQuery] The MediaQuery object
15
- # @param media_query_list_id [Integer, nil] Optional list ID if this is part of a comma-separated list
16
- # @param media_query_lists [Hash] Hash mapping list_id => array of MediaQuery IDs
17
- # @param media_queries [Array] Array of all MediaQuery objects
18
- # @return [String] Serialized media query string (e.g., "screen", "screen, print", "screen and (min-width: 768px)")
19
- def self._build_media_query_string(media_query, media_query_list_id, media_query_lists, media_queries)
20
- if media_query_list_id
21
- # Comma-separated list
22
- mq_ids = media_query_lists[media_query_list_id]
23
- mq_ids.map do |mq_id|
24
- mq = media_queries[mq_id]
25
- if mq.conditions
26
- mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
27
- else
28
- mq.type.to_s
7
+ module Backends
8
+ class PureImpl
9
+ # Serializes one stylesheet's rules to a CSS string, compact or formatted.
10
+ # One instance per #stylesheet_to_s / #stylesheet_to_formatted_s call - the
11
+ # accumulating result, the rules/media being serialized, and the
12
+ # compact-vs-formatted knobs all live on ivars instead of being threaded
13
+ # through every helper as parameters.
14
+ class Serializer
15
+ def initialize(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists, formatted:)
16
+ @rules = rules
17
+ @charset = charset
18
+ @has_nesting = has_nesting
19
+ @selector_lists = selector_lists || {}
20
+ @media_queries = media_queries || []
21
+ @media_query_lists = media_query_lists || {}
22
+ @formatted = formatted
23
+ @result = +''
24
+
25
+ if formatted
26
+ @opening_brace = " {\n"
27
+ @closing_brace = "}\n"
28
+ @media_indent = ' '
29
+ @decl_indent_base = ' '
30
+ @decl_indent_media = ' '
31
+ @add_blank_lines = true
32
+ else
33
+ @opening_brace = ' { '
34
+ @closing_brace = " }\n"
35
+ @media_indent = ''
36
+ @decl_indent_base = nil
37
+ @decl_indent_media = nil
38
+ @add_blank_lines = false
39
+ end
29
40
  end
30
- end.join(', ')
31
- else
32
- # Single query
33
- if media_query.conditions
34
- media_query.type == :all ? media_query.conditions : "#{media_query.type} and #{media_query.conditions}"
35
- else
36
- media_query.type.to_s
37
- end
38
- end
39
- end
40
41
 
41
- # Serialize stylesheet to compact CSS string
42
- #
43
- # @param rules [Array<Rule>] Array of rules
44
- # @param charset [String, nil] @charset value
45
- # @param has_nesting [Boolean] Whether any nested rules exist
46
- # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
47
- # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
48
- # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
49
- # @return [String] Compact CSS string
50
- def self.stylesheet_to_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
51
- result = +''
52
-
53
- # Add @charset if present
54
- unless charset.nil?
55
- result << "@charset \"#{charset}\";\n"
56
- end
57
-
58
- # Fast path: no nesting - use simple algorithm
59
- unless has_nesting
60
- return _stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
61
- end
42
+ # Serialize the stylesheet to a CSS string (compact or formatted,
43
+ # depending on how this Serializer was constructed).
44
+ #
45
+ # @return [String] CSS string
46
+ def to_s
47
+ @result << "@charset \"#{@charset}\";\n" unless @charset.nil?
62
48
 
63
- # Build parent-child relationships
64
- rule_children = {}
65
- rules.each do |rule|
66
- next unless rule.parent_rule_id
67
-
68
- parent_id = rule.parent_rule_id.is_a?(Integer) ? rule.parent_rule_id : rule.parent_rule_id.to_i
69
- rule_children[parent_id] ||= []
70
- rule_children[parent_id] << rule
71
- end
49
+ build_mq_id_to_list_id!
72
50
 
73
- # Build reverse map: media_query_id => list_id
74
- mq_id_to_list_id = {}
75
- media_query_lists.each do |list_id, mq_ids|
76
- mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
77
- end
78
-
79
- # Serialize top-level rules only (those without parent_rule_id)
80
- current_media_query_list_id = nil
81
- current_media_query = nil
82
- in_media_block = false
83
-
84
- rules.each do |rule|
85
- # Skip rules that have a parent (they'll be serialized as nested)
86
- next if rule.parent_rule_id
87
-
88
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
89
- rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
90
- rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
91
-
92
- if rule_media_query.nil?
93
- # Close any open media block
94
- if in_media_block
95
- result << "}\n"
96
- in_media_block = false
97
- current_media_query = nil
98
- current_media_query_list_id = nil
99
- end
100
- else
101
- # Check if we need to open a new media block
102
- # For lists: compare list_id
103
- # For single queries: compare by content (type + conditions)
104
- needs_new_block = if rule_media_query_list_id
105
- current_media_query_list_id != rule_media_query_list_id
106
- else
107
- !current_media_query ||
108
- current_media_query.type != rule_media_query.type ||
109
- current_media_query.conditions != rule_media_query.conditions
110
- end
111
-
112
- if needs_new_block
113
- if in_media_block
114
- result << "}\n"
51
+ if @has_nesting
52
+ @formatted ? serialize_with_nesting_formatted : serialize_with_nesting_compact
53
+ else
54
+ serialize_with_grouping
115
55
  end
116
- current_media_query = rule_media_query
117
- current_media_query_list_id = rule_media_query_list_id
118
56
 
119
- # Serialize the media query (or list)
120
- media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
121
- result << "@media #{media_query_string} {\n"
122
- in_media_block = true
57
+ @result
123
58
  end
124
- end
125
-
126
- _serialize_rule_with_nesting(result, rule, rule_children, media_queries)
127
- end
128
-
129
- if in_media_block
130
- result << "}\n"
131
- end
132
-
133
- result
134
- end
135
-
136
- # Helper: serialize rules without nesting support (compact format)
137
- def self._stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries = [], media_query_lists = {})
138
- _serialize_stylesheet_with_grouping(
139
- rules: rules,
140
- result: result,
141
- selector_lists: selector_lists,
142
- media_queries: media_queries,
143
- media_query_lists: media_query_lists,
144
- opening_brace: ' { ',
145
- closing_brace: " }\n",
146
- media_indent: '',
147
- decl_indent_base: nil,
148
- decl_indent_media: nil,
149
- add_blank_lines: false
150
- )
151
- end
152
-
153
- # Helper: serialize a rule with its nested children
154
- def self._serialize_rule_with_nesting(result, rule, rule_children, media_queries)
155
- # Start selector
156
- result << "#{rule.selector} { "
157
59
 
158
- # Serialize declarations
159
- has_declarations = !rule.declarations.empty?
160
- if has_declarations
161
- _serialize_declarations(result, rule.declarations)
162
- end
60
+ private
163
61
 
164
- # Get nested children for this rule
165
- children = rule_children[rule.id] || []
166
-
167
- # Serialize nested children
168
- children.each_with_index do |child, index|
169
- # Add space before nested content
170
- # - Always add space if we had declarations
171
- # - Add space between nested rules (not before first if no declarations)
172
- if has_declarations || index > 0
173
- result << ' '
174
- end
175
-
176
- # Determine if we need to reconstruct the nested selector with &
177
- nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
178
-
179
- # Check if this child has @media nesting (parent_rule_id present but nesting_style is nil)
180
- if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
181
- # This is a nested @media rule
182
- mq = media_queries[child.media_query_id]
183
- media_query_string = if mq.conditions
184
- mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
185
- else
186
- mq.type.to_s
187
- end
188
- result << "@media #{media_query_string} { "
189
- _serialize_declarations(result, child.declarations)
190
-
191
- # Recursively serialize any children of this @media rule
192
- media_children = rule_children[child.id] || []
193
- media_children.each_with_index do |media_child, media_idx|
194
- result << ' ' if media_idx > 0 || !child.declarations.empty?
195
-
196
- nested_media_selector = _reconstruct_nested_selector(
197
- child.selector, media_child.selector,
198
- media_child.nesting_style
199
- )
200
-
201
- result << "#{nested_media_selector} { "
202
- _serialize_declarations(result, media_child.declarations)
203
- result << ' }'
62
+ # Build reverse map: media_query_id => list_id (used by both the
63
+ # nesting-aware and selector-list-grouping paths)
64
+ def build_mq_id_to_list_id!
65
+ @mq_id_to_list_id = {}
66
+ @media_query_lists.each do |list_id, mq_ids|
67
+ mq_ids.each { |mq_id| @mq_id_to_list_id[mq_id] = list_id }
68
+ end
204
69
  end
205
70
 
206
- result << ' }'
207
- else
208
- # Regular nested selector
209
- result << "#{nested_selector} { "
210
- _serialize_declarations(result, child.declarations)
211
-
212
- # Recursively serialize any children of this nested rule
213
- grandchildren = rule_children[child.id] || []
214
- grandchildren.each_with_index do |grandchild, grandchild_idx|
215
- result << ' ' if grandchild_idx > 0 || !child.declarations.empty?
216
-
217
- nested_grandchild_selector = _reconstruct_nested_selector(
218
- child.selector,
219
- grandchild.selector,
220
- grandchild.nesting_style
221
- )
222
-
223
- result << "#{nested_grandchild_selector} { "
224
- _serialize_declarations(result, grandchild.declarations)
225
- result << ' }'
71
+ # Build id => rule lookup (used only by the selector-list-grouping
72
+ # path). @rules is NOT guaranteed to satisfy rules[i].id == i here -
73
+ # that invariant only holds for the full, freshly-parsed rules array;
74
+ # Stylesheet#to_s(media: ...) passes a filtered subset whenever the
75
+ # media filter isn't :all, so a plain array index would silently
76
+ # fetch the wrong rule (or even an AtRule, which has no
77
+ # #declarations) once any rule has been filtered out.
78
+ def build_rules_by_id!
79
+ @rules_by_id = {}
80
+ @rules.each { |rule| @rules_by_id[rule.id] = rule }
226
81
  end
227
82
 
228
- result << ' }'
229
- end
230
- end
231
-
232
- result << " }\n"
233
- end
234
-
235
- # Reconstruct nested selector representation
236
- # If nesting_style == 1 (explicit), try to use & notation
237
- # If nesting_style == 0 (implicit), use plain selector
238
- def self._reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
239
- return child_selector if nesting_style.nil?
240
-
241
- if nesting_style == 1 # NESTING_STYLE_EXPLICIT
242
- # Try to reconstruct & notation
243
- # ".parent .child" with parent ".parent" => "& .child"
244
- # ".parent:hover" with parent ".parent" => "&:hover"
245
- if child_selector.start_with?(parent_selector)
246
- rest = child_selector[parent_selector.length..-1]
247
- return "&#{rest}"
248
- end
249
- # More complex cases like ".parent .foo .child"
250
- child_selector.sub(parent_selector, '&')
251
- else # NESTING_STYLE_IMPLICIT
252
- # Remove parent prefix for implicit nesting
253
- # ".parent .child" with parent ".parent" => ".child"
254
- if child_selector.start_with?(parent_selector)
255
- rest = child_selector[parent_selector.length..-1]
256
- return rest.lstrip
257
- end
258
- child_selector
259
- end
260
- end
261
-
262
- # Helper: find all selectors from same list with matching declarations
263
- # Returns array of selectors that can be grouped, marks rules as processed
264
- def self._find_groupable_selectors(rule:, rules:, selector_lists:, processed_rule_ids:, current_media_query_id:)
265
- list_id = rule.selector_list_id
266
- rule_ids_in_list = selector_lists[list_id]
267
-
268
- # If no other rules in this list, return just this selector
269
- if rule_ids_in_list.nil? || rule_ids_in_list.size <= 1
270
- processed_rule_ids[rule.id] = true
271
- return [rule.selector]
272
- end
273
-
274
- # Find all rules in this list that have identical declarations AND same media context
275
- matching_selectors = []
276
- rule_ids_in_list.each do |rid|
277
- # Direct array access (O(1)) - rules[i].id == i invariant is guaranteed by parser
278
- other_rule = rules[rid]
279
- next unless other_rule
280
- next if processed_rule_ids[rid]
281
-
282
- # Check same media context (compare media_query_id directly)
283
- next if other_rule.media_query_id != current_media_query_id
284
-
285
- # Check declarations match (compare arrays directly for performance)
286
- if _declarations_equal?(rule.declarations, other_rule.declarations)
287
- matching_selectors << other_rule.selector
288
- processed_rule_ids[rid] = true
289
- end
290
- end
291
-
292
- matching_selectors
293
- end
294
-
295
- # Private shared implementation for stylesheet serialization with optional selector list grouping
296
- # All formatting behavior controlled by kwargs to avoid mode flags and if/else branches
297
- def self._serialize_stylesheet_with_grouping(
298
- rules:,
299
- result:,
300
- selector_lists:,
301
- opening_brace:, # ' { ' (compact) vs " {\n" (formatted)
302
- closing_brace:, # " }\n" (compact) vs "}\n" (formatted)
303
- media_indent:, # '' (compact) vs ' ' (formatted)
304
- decl_indent_base:, # nil (compact) vs ' ' (formatted base rules)
305
- decl_indent_media:, # nil (compact) vs ' ' (formatted media rules)
306
- add_blank_lines:, # false (compact) vs true (formatted)
307
- media_queries: [], # Array of MediaQuery objects
308
- media_query_lists: {} # Hash: list_id => array of MediaQuery IDs
309
- )
310
- grouping_enabled = selector_lists && !selector_lists.empty?
311
-
312
- # Build reverse map: media_query_id => list_id
313
- mq_id_to_list_id = {}
314
- media_query_lists.each do |list_id, mq_ids|
315
- mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
316
- end
317
-
318
- # Track processed rules to avoid duplicates when grouping
319
- processed_rule_ids = {}
320
-
321
- # Iterate through rules in insertion order, grouping consecutive media queries
322
- current_media_query_list_id = nil
323
- current_media_query = nil
324
- in_media_block = false
325
- rule_index = 0
83
+ # Build parent-child relationships (used only by the nesting-aware path)
84
+ def build_rule_children!
85
+ @rule_children = {}
86
+ @rules.each do |rule|
87
+ parent_rule_id = rule_parent_id(rule)
88
+ next unless parent_rule_id
326
89
 
327
- rules.each do |rule|
328
- # Skip if already processed (when grouped)
329
- next if processed_rule_ids[rule.id]
330
-
331
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
332
- rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
333
- rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
334
- is_first_rule = (rule_index == 0)
335
-
336
- if rule_media_query.nil?
337
- # Not in any media query - close any open media block first
338
- if in_media_block
339
- result << "}\n"
340
- in_media_block = false
341
- current_media_query = nil
342
- current_media_query_list_id = nil
343
- end
344
-
345
- # Add blank line prefix for non-first rules (formatted only)
346
- result << "\n" if add_blank_lines && !is_first_rule
347
-
348
- # Try to group with other rules from same selector list
349
- if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
350
- selectors = _find_groupable_selectors(
351
- rule: rule,
352
- rules: rules,
353
- selector_lists: selector_lists,
354
- processed_rule_ids: processed_rule_ids,
355
- current_media_query_id: rule_media_query_id
356
- )
357
-
358
- # Serialize with grouped selectors
359
- result << selectors.join(', ') << opening_brace
360
- if decl_indent_base
361
- _serialize_declarations_formatted(result, rule.declarations, decl_indent_base)
362
- else
363
- _serialize_declarations(result, rule.declarations)
364
- end
365
- result << closing_brace
366
- else
367
- # Serialize individual rule
368
- if decl_indent_base
369
- _serialize_rule_formatted(result, rule, '', true)
370
- else
371
- _serialize_rule(result, rule)
90
+ parent_id = parent_rule_id.is_a?(Integer) ? parent_rule_id : parent_rule_id.to_i
91
+ @rule_children[parent_id] ||= []
92
+ @rule_children[parent_id] << rule
372
93
  end
373
- processed_rule_ids[rule.id] = true
374
94
  end
375
- else
376
- # This rule is in a media query
377
- # For lists: compare list_id
378
- # For single queries: compare by content (type + conditions)
379
- needs_new_block = if rule_media_query_list_id
380
- current_media_query_list_id != rule_media_query_list_id
381
- else
382
- !current_media_query ||
383
- current_media_query.type != rule_media_query.type ||
384
- current_media_query.conditions != rule_media_query.conditions
385
- end
386
-
387
- if needs_new_block
388
- # Close previous media block if open
389
- if in_media_block
390
- result << "}\n"
391
- end
392
-
393
- # Add blank line prefix for non-first rules (formatted only)
394
- result << "\n" if add_blank_lines && !is_first_rule
395
95
 
396
- # Open new media block
397
- current_media_query = rule_media_query
398
- current_media_query_list_id = rule_media_query_list_id
399
-
400
- # Serialize the media query (or list)
401
- media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
402
- result << "@media #{media_query_string} {\n"
403
- in_media_block = true
96
+ # Read a rule's parent_rule_id, guarding against AtRule (which has no
97
+ # such member - only Rule participates in CSS nesting, AtRule never is
98
+ # nested via '&' nesting). Calling .parent_rule_id directly on an AtRule
99
+ # raises NoMethodError.
100
+ def rule_parent_id(rule)
101
+ rule.is_a?(Rule) ? rule.parent_rule_id : nil
404
102
  end
405
103
 
406
- # Try to group with other rules from same selector list
407
- if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
408
- selectors = _find_groupable_selectors(
409
- rule: rule,
410
- rules: rules,
411
- selector_lists: selector_lists,
412
- processed_rule_ids: processed_rule_ids,
413
- current_media_query_id: rule_media_query_id
414
- )
415
-
416
- # Serialize with grouped selectors (with media indent)
417
- result << media_indent << selectors.join(', ') << opening_brace
418
- if decl_indent_media
419
- _serialize_declarations_formatted(result, rule.declarations, decl_indent_media)
104
+ # Build media query string from a MediaQuery object or list.
105
+ # @param media_query [MediaQuery] The MediaQuery object
106
+ # @param media_query_list_id [Integer, nil] Optional list ID if this is part of a comma-separated list
107
+ # @return [String] e.g. "screen", "screen, print", "screen and (min-width: 768px)"
108
+ def build_media_query_string(media_query, media_query_list_id)
109
+ if media_query_list_id
110
+ mq_ids = @media_query_lists[media_query_list_id]
111
+ mq_ids.map do |mq_id|
112
+ mq = @media_queries[mq_id]
113
+ if mq.conditions
114
+ mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
115
+ else
116
+ mq.type.to_s
117
+ end
118
+ end.join(', ')
119
+ elsif media_query.conditions
120
+ media_query.type == :all ? media_query.conditions : "#{media_query.type} and #{media_query.conditions}"
420
121
  else
421
- _serialize_declarations(result, rule.declarations)
122
+ media_query.type.to_s
422
123
  end
423
- result << media_indent << closing_brace
424
- else
425
- # Serialize individual rule inside media block
426
- if decl_indent_media
427
- _serialize_rule_formatted(result, rule, media_indent, true)
124
+ end
125
+
126
+ # Does the rule's media query differ from the currently open media
127
+ # block, requiring a new "@media ... {" to be opened? Comma-separated
128
+ # lists are compared by list id; single queries are compared by content
129
+ # (type + conditions), since two separately-parsed queries with the same
130
+ # content should still be grouped under one block.
131
+ def needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
132
+ rule_media_query_list_id)
133
+ if rule_media_query_list_id
134
+ current_media_query_list_id != rule_media_query_list_id
428
135
  else
429
- _serialize_rule(result, rule)
136
+ !current_media_query ||
137
+ current_media_query.type != rule_media_query.type ||
138
+ current_media_query.conditions != rule_media_query.conditions
430
139
  end
431
- processed_rule_ids[rule.id] = true
432
140
  end
433
- end
434
141
 
435
- rule_index += 1
436
- end
142
+ # ---- Nesting-aware path (has_nesting == true), compact format ----
437
143
 
438
- # Close final media block if still open
439
- if in_media_block
440
- result << "}\n"
441
- end
144
+ def serialize_with_nesting_compact
145
+ build_rule_children!
442
146
 
443
- result
444
- end
445
- private_class_method :_serialize_stylesheet_with_grouping
147
+ current_media_query_list_id = nil
148
+ current_media_query = nil
149
+ in_media_block = false
446
150
 
447
- # Helper: check if two declaration arrays are equal
448
- def self._declarations_equal?(decls1, decls2)
449
- return false if decls1.size != decls2.size
151
+ @rules.each do |rule|
152
+ next if rule_parent_id(rule)
153
+
154
+ rule_media_query_id = rule.media_query_id
155
+ rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
156
+ rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
157
+
158
+ if rule_media_query.nil?
159
+ if in_media_block
160
+ @result << "}\n"
161
+ in_media_block = false
162
+ current_media_query = nil
163
+ current_media_query_list_id = nil
164
+ end
165
+ else
166
+ needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
167
+ rule_media_query, rule_media_query_list_id)
168
+
169
+ if needs_new_block
170
+ @result << "}\n" if in_media_block
171
+ current_media_query = rule_media_query
172
+ current_media_query_list_id = rule_media_query_list_id
173
+
174
+ media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
175
+ @result << "@media #{media_query_string} {\n"
176
+ in_media_block = true
177
+ end
178
+ end
179
+
180
+ serialize_rule_with_nesting(rule)
181
+ end
450
182
 
451
- decls1.each_with_index do |d1, i|
452
- d2 = decls2[i]
453
- return false if d1.property != d2.property
454
- return false if d1.value != d2.value
455
- return false if d1.important != d2.important
456
- end
183
+ @result << "}\n" if in_media_block
184
+ end
457
185
 
458
- true
459
- end
186
+ def serialize_rule_with_nesting(rule)
187
+ # AtRule can never have nested children of its own via @rule_children
188
+ # (only Rule participates in CSS nesting), so it always serializes the
189
+ # same way regardless of has_nesting.
190
+ if rule.is_a?(AtRule)
191
+ serialize_at_rule(rule)
192
+ return
193
+ end
460
194
 
461
- # Helper: serialize a single rule
462
- def self._serialize_rule(result, rule)
463
- # Check if this is an AtRule
464
- if rule.is_a?(AtRule)
465
- _serialize_at_rule(result, rule)
466
- return
467
- end
195
+ @result << "#{rule.selector} { "
468
196
 
469
- # Regular Rule serialization
470
- result << "#{rule.selector} { "
471
- _serialize_declarations(result, rule.declarations)
472
- result << " }\n"
473
- end
197
+ has_declarations = !rule.declarations.empty?
198
+ serialize_declarations(rule.declarations) if has_declarations
474
199
 
475
- # Helper: serialize declarations (compact, single line)
476
- def self._serialize_declarations(result, declarations)
477
- declarations.each_with_index do |decl, i|
478
- important_suffix = decl.important ? ' !important;' : ';'
479
- separator = i < declarations.length - 1 ? ' ' : ''
480
- result << "#{decl.property}: #{decl.value}#{important_suffix}#{separator}"
481
- end
482
- end
200
+ serialize_children(rule.selector, @rule_children[rule.id] || [], has_declarations)
483
201
 
484
- # Helper: serialize declarations (formatted, one per line)
485
- def self._serialize_declarations_formatted(result, declarations, indent)
486
- declarations.each do |decl|
487
- result << indent
488
- result << decl.property
489
- result << ': '
490
- result << decl.value
202
+ @result << " }\n"
203
+ end
491
204
 
492
- if decl.important
493
- result << ' !important'
494
- end
205
+ # Recursively serialize a rule's nested children. CSS nesting can go as
206
+ # deep as the parser allows (MAX_PARSE_DEPTH), so this recurses rather
207
+ # than hand-unrolling a fixed number of levels - a nested rule's own
208
+ # nested rules are found the same way its parent's were, via
209
+ # @rule_children[id].
210
+ def serialize_children(parent_selector, children, parent_has_declarations)
211
+ children.each_with_index do |child, index|
212
+ # Add space before nested content - always if the parent had
213
+ # declarations, otherwise between nested rules (not before the first).
214
+ @result << ' ' if parent_has_declarations || index > 0
215
+
216
+ if child.nesting_style.nil? && child.media_query_id && @media_queries[child.media_query_id]
217
+ # Nested @media rule (parent_rule_id present but nesting_style is nil)
218
+ mq = @media_queries[child.media_query_id]
219
+ media_query_string = build_media_query_string(mq, nil)
220
+ @result << "@media #{media_query_string} { "
221
+ serialize_declarations(child.declarations)
222
+
223
+ media_children = @rule_children[child.id] || []
224
+ media_children.each_with_index do |media_child, media_idx|
225
+ @result << ' ' if media_idx > 0 || !child.declarations.empty?
226
+
227
+ nested_media_selector = reconstruct_nested_selector(
228
+ child.selector, media_child.selector, media_child.nesting_style
229
+ )
230
+
231
+ @result << "#{nested_media_selector} { "
232
+ serialize_declarations(media_child.declarations)
233
+ @result << ' }'
234
+ end
235
+
236
+ @result << ' }'
237
+ else
238
+ # Regular nested selector - reconstruct it with & if needed
239
+ nested_selector = reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
240
+
241
+ @result << "#{nested_selector} { "
242
+ serialize_declarations(child.declarations)
243
+
244
+ serialize_children(child.selector, @rule_children[child.id] || [], !child.declarations.empty?)
245
+
246
+ @result << ' }'
247
+ end
248
+ end
249
+ end
495
250
 
496
- result << ";\n"
497
- end
498
- end
251
+ # Reconstruct nested selector representation.
252
+ # If nesting_style == 1 (explicit), try to use & notation.
253
+ # If nesting_style == 0 (implicit), use plain selector.
254
+ def reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
255
+ return child_selector if nesting_style.nil?
256
+
257
+ if nesting_style == 1 # NESTING_STYLE_EXPLICIT
258
+ # ".parent .child" with parent ".parent" => "& .child"
259
+ # ".parent:hover" with parent ".parent" => "&:hover"
260
+ if child_selector.start_with?(parent_selector)
261
+ rest = child_selector[parent_selector.length..-1]
262
+ return "&#{rest}"
263
+ end
264
+ # More complex cases like ".parent .foo .child"
265
+ child_selector.sub(parent_selector, '&')
266
+ else # NESTING_STYLE_IMPLICIT
267
+ # ".parent .child" with parent ".parent" => ".child"
268
+ if child_selector.start_with?(parent_selector)
269
+ rest = child_selector[parent_selector.length..-1]
270
+ return rest.lstrip
271
+ end
272
+ child_selector
273
+ end
274
+ end
499
275
 
500
- # Helper: serialize an at-rule (@keyframes, @font-face, etc)
501
- def self._serialize_at_rule(result, at_rule)
502
- result << "#{at_rule.selector} {\n"
276
+ # ---- Selector-list-grouping path (has_nesting == false) ----
277
+ # Shared between compact and formatted output - all formatting behavior
278
+ # is controlled by ivars set in #initialize, so there's no mode-flag
279
+ # kwarg pile to keep in sync.
503
280
 
504
- # Check if content is rules or declarations
505
- if at_rule.content.length > 0
506
- first = at_rule.content[0]
281
+ def serialize_with_grouping
282
+ @processed_rule_ids = {}
283
+ grouping_enabled = @selector_lists && !@selector_lists.empty?
284
+ build_rules_by_id! if grouping_enabled
507
285
 
508
- if first.is_a?(Rule)
509
- # Serialize as nested rules (e.g., @keyframes)
510
- at_rule.content.each do |nested_rule|
511
- result << " #{nested_rule.selector} { "
512
- _serialize_declarations(result, nested_rule.declarations)
513
- result << " }\n"
286
+ current_media_query_list_id = nil
287
+ current_media_query = nil
288
+ in_media_block = false
289
+ rule_index = 0
290
+
291
+ @rules.each do |rule|
292
+ next if @processed_rule_ids[rule.id]
293
+
294
+ rule_media_query_id = rule.media_query_id
295
+ rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
296
+ rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
297
+ is_first_rule = (rule_index == 0)
298
+
299
+ if rule_media_query.nil?
300
+ if in_media_block
301
+ @result << "}\n"
302
+ in_media_block = false
303
+ current_media_query = nil
304
+ current_media_query_list_id = nil
305
+ end
306
+
307
+ @result << "\n" if @add_blank_lines && !is_first_rule
308
+
309
+ if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
310
+ selectors = find_groupable_selectors(rule, rule_media_query_id)
311
+
312
+ @result << selectors.join(', ') << @opening_brace
313
+ if @decl_indent_base
314
+ serialize_declarations_formatted(rule.declarations, @decl_indent_base)
315
+ else
316
+ serialize_declarations(rule.declarations)
317
+ end
318
+ @result << @closing_brace
319
+ else
320
+ if @decl_indent_base
321
+ serialize_rule_formatted(rule, '', true)
322
+ else
323
+ serialize_rule(rule)
324
+ end
325
+ @processed_rule_ids[rule.id] = true
326
+ end
327
+ else
328
+ needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
329
+ rule_media_query, rule_media_query_list_id)
330
+
331
+ if needs_new_block
332
+ @result << "}\n" if in_media_block
333
+ @result << "\n" if @add_blank_lines && !is_first_rule
334
+
335
+ current_media_query = rule_media_query
336
+ current_media_query_list_id = rule_media_query_list_id
337
+
338
+ media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
339
+ @result << "@media #{media_query_string} {\n"
340
+ in_media_block = true
341
+ end
342
+
343
+ if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
344
+ selectors = find_groupable_selectors(rule, rule_media_query_id)
345
+
346
+ @result << @media_indent << selectors.join(', ') << @opening_brace
347
+ if @decl_indent_media
348
+ serialize_declarations_formatted(rule.declarations, @decl_indent_media)
349
+ else
350
+ serialize_declarations(rule.declarations)
351
+ end
352
+ @result << @media_indent << @closing_brace
353
+ else
354
+ if @decl_indent_media
355
+ serialize_rule_formatted(rule, @media_indent, true)
356
+ else
357
+ serialize_rule(rule)
358
+ end
359
+ @processed_rule_ids[rule.id] = true
360
+ end
361
+ end
362
+
363
+ rule_index += 1
364
+ end
365
+
366
+ @result << "}\n" if in_media_block
514
367
  end
515
- else
516
- # Serialize as declarations (e.g., @font-face)
517
- result << ' '
518
- _serialize_declarations(result, at_rule.content)
519
- result << "\n"
520
- end
521
- end
522
368
 
523
- result << "}\n"
524
- end
369
+ # Find all selectors from the same selector list with matching
370
+ # declarations (and the same media context). Returns the array of
371
+ # selectors that can be grouped, marking them processed as it goes.
372
+ def find_groupable_selectors(rule, current_media_query_id)
373
+ list_id = rule.selector_list_id
374
+ rule_ids_in_list = @selector_lists[list_id]
525
375
 
526
- # Serialize stylesheet to formatted CSS string (with indentation)
527
- #
528
- # @param rules [Array<Rule>] Array of rules
529
- # @param charset [String, nil] @charset value
530
- # @param has_nesting [Boolean] Whether any nested rules exist
531
- # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
532
- # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
533
- # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
534
- # @return [String] Formatted CSS string
535
- def self.stylesheet_to_formatted_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
536
- result = +''
537
-
538
- # Add @charset if present
539
- unless charset.nil?
540
- result << "@charset \"#{charset}\";\n"
541
- end
376
+ if rule_ids_in_list.nil? || rule_ids_in_list.size <= 1
377
+ @processed_rule_ids[rule.id] = true
378
+ return [rule.selector]
379
+ end
542
380
 
543
- # Fast path: no nesting - use simple algorithm
544
- unless has_nesting
545
- return _stylesheet_to_formatted_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
546
- end
381
+ matching_selectors = []
382
+ rule_ids_in_list.each do |rid|
383
+ other_rule = @rules_by_id[rid]
384
+ next unless other_rule
385
+ next if @processed_rule_ids[rid]
386
+ next if other_rule.media_query_id != current_media_query_id
387
+
388
+ if declarations_equal?(rule.declarations, other_rule.declarations)
389
+ matching_selectors << other_rule.selector
390
+ @processed_rule_ids[rid] = true
391
+ end
392
+ end
547
393
 
548
- # Build parent-child relationships
549
- rule_children = {}
550
- rules.each do |rule|
551
- next unless rule.parent_rule_id
394
+ matching_selectors
395
+ end
552
396
 
553
- parent_id = rule.parent_rule_id.is_a?(Integer) ? rule.parent_rule_id : rule.parent_rule_id.to_i
554
- rule_children[parent_id] ||= []
555
- rule_children[parent_id] << rule
556
- end
397
+ def declarations_equal?(decls1, decls2)
398
+ return false if decls1.size != decls2.size
557
399
 
558
- # Build reverse map: media_query_id => list_id
559
- mq_id_to_list_id = {}
560
- media_query_lists.each do |list_id, mq_ids|
561
- mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
562
- end
400
+ decls1.each_with_index do |d1, i|
401
+ d2 = decls2[i]
402
+ return false if d1.property != d2.property
403
+ return false if d1.value != d2.value
404
+ return false if d1.important != d2.important
405
+ end
563
406
 
564
- # Serialize top-level rules only
565
- current_media_query_list_id = nil
566
- current_media_query = nil
567
- in_media_block = false
407
+ true
408
+ end
568
409
 
569
- rules.each do |rule|
570
- next if rule.parent_rule_id
410
+ def serialize_rule(rule)
411
+ if rule.is_a?(AtRule)
412
+ serialize_at_rule(rule)
413
+ return
414
+ end
571
415
 
572
- rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
573
- rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
574
- rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
416
+ @result << "#{rule.selector} { "
417
+ serialize_declarations(rule.declarations)
418
+ @result << " }\n"
419
+ end
575
420
 
576
- if rule_media_query.nil?
577
- if in_media_block
578
- result << "}\n"
579
- in_media_block = false
580
- current_media_query = nil
581
- current_media_query_list_id = nil
421
+ # Declarations, compact (single line)
422
+ def serialize_declarations(declarations)
423
+ declarations.each_with_index do |decl, i|
424
+ important_suffix = decl.important ? ' !important;' : ';'
425
+ separator = i < declarations.length - 1 ? ' ' : ''
426
+ @result << "#{decl.property}: #{decl.value}#{important_suffix}#{separator}"
427
+ end
582
428
  end
583
429
 
584
- # Add blank line before base rule if we just closed a media block (ends with "}\n")
585
- result << "\n" if result.length > 1 && result.getbyte(-1) == BYTE_NEWLINE && result.getbyte(-2) == BYTE_RBRACE
586
-
587
- _serialize_rule_with_nesting_formatted(result, rule, rule_children, '', media_queries)
588
- else
589
- # For lists: compare list_id
590
- # For single queries: compare by content (type + conditions)
591
- needs_new_block = if rule_media_query_list_id
592
- current_media_query_list_id != rule_media_query_list_id
593
- else
594
- !current_media_query ||
595
- current_media_query.type != rule_media_query.type ||
596
- current_media_query.conditions != rule_media_query.conditions
597
- end
598
-
599
- if needs_new_block
600
- if in_media_block
601
- result << "}\n"
602
- elsif result.length > 0
603
- result << "\n"
430
+ # Declarations, formatted (one per line)
431
+ def serialize_declarations_formatted(declarations, indent)
432
+ declarations.each do |decl|
433
+ @result << indent
434
+ @result << decl.property
435
+ @result << ': '
436
+ @result << decl.value
437
+ @result << ' !important' if decl.important
438
+ @result << ";\n"
604
439
  end
605
- current_media_query = rule_media_query
606
- current_media_query_list_id = rule_media_query_list_id
607
- # Serialize the media query (or list)
608
- media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
609
- result << "@media #{media_query_string} {\n"
610
- in_media_block = true
611
440
  end
612
441
 
613
- _serialize_rule_with_nesting_formatted(result, rule, rule_children, ' ', media_queries)
614
- end
615
- end
442
+ # An at-rule (@keyframes, @font-face, etc), compact
443
+ def serialize_at_rule(at_rule)
444
+ @result << "#{at_rule.selector} {\n"
445
+
446
+ if at_rule.content.length > 0
447
+ first = at_rule.content[0]
448
+
449
+ if first.is_a?(Rule)
450
+ at_rule.content.each do |nested_rule|
451
+ @result << " #{nested_rule.selector} { "
452
+ serialize_declarations(nested_rule.declarations)
453
+ @result << " }\n"
454
+ end
455
+ else
456
+ @result << ' '
457
+ serialize_declarations(at_rule.content)
458
+ @result << "\n"
459
+ end
460
+ end
616
461
 
617
- if in_media_block
618
- result << "}\n"
619
- end
462
+ @result << "}\n"
463
+ end
620
464
 
621
- result
622
- end
465
+ # ---- Nesting-aware path (has_nesting == true), formatted ----
623
466
 
624
- # Helper: formatted serialization without nesting support
625
- def self._stylesheet_to_formatted_s_without_nesting(rules, result, selector_lists, media_queries = [], media_query_lists = {})
626
- _serialize_stylesheet_with_grouping(
627
- rules: rules,
628
- result: result,
629
- selector_lists: selector_lists,
630
- media_queries: media_queries,
631
- media_query_lists: media_query_lists,
632
- opening_brace: " {\n",
633
- closing_brace: "}\n",
634
- media_indent: ' ',
635
- decl_indent_base: ' ',
636
- decl_indent_media: ' ',
637
- add_blank_lines: true
638
- )
639
- end
467
+ def serialize_with_nesting_formatted
468
+ build_rule_children!
640
469
 
641
- # Helper: serialize a rule with nested children (formatted)
642
- def self._serialize_rule_with_nesting_formatted(result, rule, rule_children, indent, media_queries)
643
- # Selector line with opening brace
644
- result << indent
645
- result << rule.selector
646
- result << " {\n"
470
+ current_media_query_list_id = nil
471
+ current_media_query = nil
472
+ in_media_block = false
647
473
 
648
- # Serialize declarations (one per line)
649
- unless rule.declarations.empty?
650
- _serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
651
- end
474
+ @rules.each do |rule|
475
+ next if rule_parent_id(rule)
476
+
477
+ rule_media_query_id = rule.media_query_id
478
+ rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
479
+ rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
480
+
481
+ if rule_media_query.nil?
482
+ if in_media_block
483
+ @result << "}\n"
484
+ in_media_block = false
485
+ current_media_query = nil
486
+ current_media_query_list_id = nil
487
+ end
488
+
489
+ # Blank line before a base rule if we just closed a media block (ends with "}\n")
490
+ if @result.length > 1 && @result.getbyte(-1) == BYTE_NEWLINE && @result.getbyte(-2) == BYTE_RBRACE
491
+ @result << "\n"
492
+ end
493
+
494
+ serialize_rule_with_nesting_formatted(rule, '')
495
+ else
496
+ needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
497
+ rule_media_query, rule_media_query_list_id)
498
+
499
+ if needs_new_block
500
+ if in_media_block
501
+ @result << "}\n"
502
+ elsif @result.length > 0
503
+ @result << "\n"
504
+ end
505
+ current_media_query = rule_media_query
506
+ current_media_query_list_id = rule_media_query_list_id
507
+ media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
508
+ @result << "@media #{media_query_string} {\n"
509
+ in_media_block = true
510
+ end
511
+
512
+ serialize_rule_with_nesting_formatted(rule, ' ')
513
+ end
514
+ end
652
515
 
653
- # Get nested children
654
- children = rule_children[rule.id] || []
655
-
656
- # Serialize nested children
657
- children.each do |child|
658
- nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
659
-
660
- if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
661
- # Nested @media
662
- mq = media_queries[child.media_query_id]
663
- media_query_string = if mq.conditions
664
- mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
665
- else
666
- mq.type.to_s
667
- end
668
- result << indent
669
- result << " @media #{media_query_string} {\n"
670
-
671
- unless child.declarations.empty?
672
- _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
516
+ @result << "}\n" if in_media_block
673
517
  end
674
518
 
675
- # Recursively handle media children
676
- media_children = rule_children[child.id] || []
677
- media_children.each do |media_child|
678
- nested_media_selector = _reconstruct_nested_selector(
679
- child.selector,
680
- media_child.selector,
681
- media_child.nesting_style
682
- )
683
-
684
- result << indent
685
- result << " #{nested_media_selector} {\n"
686
- unless media_child.declarations.empty?
687
- _serialize_declarations_formatted(result, media_child.declarations, "#{indent} ")
519
+ def serialize_rule_with_nesting_formatted(rule, indent)
520
+ if rule.is_a?(AtRule)
521
+ serialize_at_rule_formatted(rule, indent)
522
+ return
688
523
  end
689
- result << indent
690
- result << " }\n"
691
- end
692
524
 
693
- result << indent
694
- result << " }\n"
695
- else
696
- # Regular nested selector
697
- result << indent
698
- result << " #{nested_selector} {\n"
525
+ @result << indent
526
+ @result << rule.selector
527
+ @result << " {\n"
528
+
529
+ serialize_declarations_formatted(rule.declarations, "#{indent} ") unless rule.declarations.empty?
530
+
531
+ serialize_children_formatted(rule.selector, @rule_children[rule.id] || [], "#{indent} ")
699
532
 
700
- unless child.declarations.empty?
701
- _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
533
+ @result << indent
534
+ @result << "}\n"
702
535
  end
703
536
 
704
- # Recursively handle grandchildren
705
- grandchildren = rule_children[child.id] || []
706
- grandchildren.each do |grandchild|
707
- nested_grandchild_selector = _reconstruct_nested_selector(
708
- child.selector,
709
- grandchild.selector,
710
- grandchild.nesting_style
711
- )
712
-
713
- result << indent
714
- result << " #{nested_grandchild_selector} {\n"
715
- unless grandchild.declarations.empty?
716
- _serialize_declarations_formatted(result, grandchild.declarations, "#{indent} ")
537
+ # Recursively serialize a rule's nested children with indentation. CSS
538
+ # nesting can go as deep as the parser allows (MAX_PARSE_DEPTH), so this
539
+ # recurses rather than hand-unrolling a fixed number of levels - a nested
540
+ # rule's own nested rules are found the same way its parent's were, via
541
+ # @rule_children[id], with the indent growing by one level each call.
542
+ def serialize_children_formatted(parent_selector, children, indent)
543
+ children.each do |child|
544
+ if child.nesting_style.nil? && child.media_query_id && @media_queries[child.media_query_id]
545
+ mq = @media_queries[child.media_query_id]
546
+ media_query_string = build_media_query_string(mq, nil)
547
+ @result << indent
548
+ @result << "@media #{media_query_string} {\n"
549
+
550
+ serialize_declarations_formatted(child.declarations, "#{indent} ") unless child.declarations.empty?
551
+
552
+ media_children = @rule_children[child.id] || []
553
+ media_children.each do |media_child|
554
+ nested_media_selector = reconstruct_nested_selector(
555
+ child.selector, media_child.selector, media_child.nesting_style
556
+ )
557
+
558
+ @result << indent
559
+ @result << " #{nested_media_selector} {\n"
560
+ unless media_child.declarations.empty?
561
+ serialize_declarations_formatted(media_child.declarations, "#{indent} ")
562
+ end
563
+ @result << indent
564
+ @result << " }\n"
565
+ end
566
+
567
+ @result << indent
568
+ @result << "}\n"
569
+ else
570
+ nested_selector = reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
571
+
572
+ @result << indent
573
+ @result << "#{nested_selector} {\n"
574
+
575
+ serialize_declarations_formatted(child.declarations, "#{indent} ") unless child.declarations.empty?
576
+
577
+ serialize_children_formatted(child.selector, @rule_children[child.id] || [], "#{indent} ")
578
+
579
+ @result << indent
580
+ @result << "}\n"
581
+ end
717
582
  end
718
- result << indent
719
- result << " }\n"
720
583
  end
721
584
 
722
- result << indent
723
- result << " }\n"
724
- end
725
- end
726
-
727
- # Closing brace
728
- result << indent
729
- result << "}\n"
730
- end
585
+ def serialize_rule_formatted(rule, indent, is_last_rule = false)
586
+ if rule.is_a?(AtRule)
587
+ serialize_at_rule_formatted(rule, indent)
588
+ return
589
+ end
731
590
 
732
- # Helper: serialize a single rule with formatting
733
- def self._serialize_rule_formatted(result, rule, indent, is_last_rule = false)
734
- # Check if this is an AtRule
735
- if rule.is_a?(AtRule)
736
- _serialize_at_rule_formatted(result, rule, indent)
737
- return
738
- end
591
+ @result << indent
592
+ @result << rule.selector
593
+ @result << " {\n"
739
594
 
740
- # Regular Rule serialization with formatting
741
- # Selector line with opening brace
742
- result << indent
743
- result << rule.selector
744
- result << " {\n"
595
+ serialize_declarations_formatted(rule.declarations, "#{indent} ")
745
596
 
746
- # Declarations (one per line)
747
- _serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
597
+ # Closing brace - double newline for all except last rule
598
+ @result << indent
599
+ @result << (is_last_rule ? "}\n" : "}\n\n")
600
+ end
748
601
 
749
- # Closing brace - double newline for all except last rule
750
- result << indent
751
- result << (is_last_rule ? "}\n" : "}\n\n")
752
- end
602
+ def serialize_at_rule_formatted(at_rule, indent)
603
+ @result << indent
604
+ @result << at_rule.selector
605
+ @result << " {\n"
606
+
607
+ if at_rule.content.length > 0
608
+ first = at_rule.content[0]
609
+
610
+ if first.is_a?(Rule)
611
+ at_rule.content.each do |nested_rule|
612
+ @result << indent
613
+ @result << ' '
614
+ @result << nested_rule.selector
615
+ @result << " {\n"
616
+
617
+ serialize_declarations_formatted(nested_rule.declarations, "#{indent} ")
618
+
619
+ @result << indent
620
+ @result << " }\n"
621
+ end
622
+ else
623
+ serialize_declarations_formatted(at_rule.content, "#{indent} ")
624
+ end
625
+ end
753
626
 
754
- # Helper: serialize an at-rule with formatting
755
- def self._serialize_at_rule_formatted(result, at_rule, indent)
756
- result << indent
757
- result << at_rule.selector
758
- result << " {\n"
759
-
760
- # Check if content is rules or declarations
761
- if at_rule.content.length > 0
762
- first = at_rule.content[0]
763
-
764
- if first.is_a?(Rule)
765
- # Serialize as nested rules (e.g., @keyframes) with formatting
766
- at_rule.content.each do |nested_rule|
767
- # Nested selector with opening brace (2-space indent)
768
- result << indent
769
- result << ' '
770
- result << nested_rule.selector
771
- result << " {\n"
772
-
773
- # Declarations (one per line, 4-space indent)
774
- _serialize_declarations_formatted(result, nested_rule.declarations, "#{indent} ")
775
-
776
- # Closing brace (2-space indent)
777
- result << indent
778
- result << " }\n"
627
+ @result << indent
628
+ @result << "}\n"
779
629
  end
780
- else
781
- # Serialize as declarations (e.g., @font-face, one per line)
782
- _serialize_declarations_formatted(result, at_rule.content, "#{indent} ")
783
630
  end
784
- end
785
631
 
786
- result << indent
787
- result << "}\n"
788
- end
632
+ # Serialize stylesheet to compact CSS string.
633
+ #
634
+ # @param rules [Array<Rule>] Array of rules
635
+ # @param charset [String, nil] @charset value
636
+ # @param has_nesting [Boolean] Whether any nested rules exist
637
+ # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
638
+ # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects
639
+ # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (for comma-separated queries)
640
+ # @return [String] Compact CSS string
641
+ def stylesheet_to_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
642
+ Serializer.new(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists, formatted: false).to_s
643
+ end
789
644
 
790
- # Mark helper methods as private (public APIs: stylesheet_to_s, stylesheet_to_formatted_s)
791
- private_class_method :_build_media_query_string, :_stylesheet_to_s_without_nesting, :_serialize_rule_with_nesting,
792
- :_reconstruct_nested_selector, :_find_groupable_selectors, :_declarations_equal?,
793
- :_serialize_rule, :_serialize_declarations, :_serialize_declarations_formatted,
794
- :_serialize_at_rule, :_stylesheet_to_formatted_s_without_nesting, :_serialize_rule_with_nesting_formatted,
795
- :_serialize_rule_formatted, :_serialize_at_rule_formatted
645
+ # Serialize stylesheet to formatted CSS string (with indentation).
646
+ #
647
+ # @param rules [Array<Rule>] Array of rules
648
+ # @param charset [String, nil] @charset value
649
+ # @param has_nesting [Boolean] Whether any nested rules exist
650
+ # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
651
+ # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects
652
+ # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (for comma-separated queries)
653
+ # @return [String] Formatted CSS string
654
+ def stylesheet_to_formatted_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [],
655
+ media_query_lists = {})
656
+ Serializer.new(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists,
657
+ formatted: true).to_s
658
+ end
659
+ end
660
+ end
796
661
  end