cataract 0.3.0 → 0.4.0

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