cataract 0.2.1 → 0.2.2

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.
@@ -10,8 +10,9 @@ module Cataract
10
10
  # @param media_index [Hash] Media query symbol => array of rule IDs
11
11
  # @param charset [String, nil] @charset value
12
12
  # @param has_nesting [Boolean] Whether any nested rules exist
13
+ # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
13
14
  # @return [String] Compact CSS string
14
- def self._stylesheet_to_s(rules, media_index, charset, has_nesting)
15
+ def self._stylesheet_to_s(rules, media_index, charset, has_nesting, selector_lists = {})
15
16
  result = +''
16
17
 
17
18
  # Add @charset if present
@@ -21,7 +22,7 @@ module Cataract
21
22
 
22
23
  # Fast path: no nesting - use simple algorithm
23
24
  unless has_nesting
24
- return stylesheet_to_s_original(rules, media_index, result)
25
+ return stylesheet_to_s_original(rules, media_index, result, selector_lists)
25
26
  end
26
27
 
27
28
  # Build parent-child relationships
@@ -81,55 +82,20 @@ module Cataract
81
82
  result
82
83
  end
83
84
 
84
- # Helper: serialize rules without nesting support
85
- def self.stylesheet_to_s_original(rules, media_index, result)
86
- # Build rule_id => media_symbol map
87
- rule_to_media = {}
88
- media_index.each do |media_sym, rule_ids|
89
- rule_ids.each do |rule_id|
90
- rule_to_media[rule_id] = media_sym
91
- end
92
- end
93
-
94
- # Iterate through rules in insertion order, grouping consecutive media queries
95
- current_media = nil
96
- in_media_block = false
97
-
98
- rules.each do |rule|
99
- rule_media = rule_to_media[rule.id]
100
-
101
- if rule_media.nil?
102
- # Not in any media query - close any open media block first
103
- if in_media_block
104
- result << "}\n"
105
- in_media_block = false
106
- current_media = nil
107
- end
108
- else
109
- # This rule is in a media query
110
- # Check if media query changed from previous rule
111
- if current_media.nil? || current_media != rule_media
112
- # Close previous media block if open
113
- if in_media_block
114
- result << "}\n"
115
- end
116
-
117
- # Open new media block
118
- current_media = rule_media
119
- result << "@media #{current_media} {\n"
120
- in_media_block = true
121
- end
122
- end
123
-
124
- serialize_rule(result, rule)
125
- end
126
-
127
- # Close final media block if still open
128
- if in_media_block
129
- result << "}\n"
130
- end
131
-
132
- result
85
+ # Helper: serialize rules without nesting support (compact format)
86
+ def self.stylesheet_to_s_original(rules, media_index, result, selector_lists)
87
+ _serialize_stylesheet_with_grouping(
88
+ rules: rules,
89
+ media_index: media_index,
90
+ result: result,
91
+ selector_lists: selector_lists,
92
+ opening_brace: ' { ',
93
+ closing_brace: " }\n",
94
+ media_indent: '',
95
+ decl_indent_base: nil,
96
+ decl_indent_media: nil,
97
+ add_blank_lines: false
98
+ )
133
99
  end
134
100
 
135
101
  # Helper: serialize a rule with its nested children
@@ -236,6 +202,190 @@ module Cataract
236
202
  end
237
203
  end
238
204
 
205
+ # Helper: find all selectors from same list with matching declarations
206
+ # Returns array of selectors that can be grouped, marks rules as processed
207
+ def self.find_groupable_selectors(rule:, rules:, selector_lists:, processed_rule_ids:, rule_to_media:, current_media:)
208
+ list_id = rule.selector_list_id
209
+ rule_ids_in_list = selector_lists[list_id]
210
+
211
+ # If no other rules in this list, return just this selector
212
+ if rule_ids_in_list.nil? || rule_ids_in_list.size <= 1
213
+ processed_rule_ids[rule.id] = true
214
+ return [rule.selector]
215
+ end
216
+
217
+ # Find all rules in this list that have identical declarations AND same media context
218
+ matching_selectors = []
219
+ rule_ids_in_list.each do |rid|
220
+ # Find the rule by ID
221
+ other_rule = rules.find { |r| r.id == rid }
222
+ next unless other_rule
223
+ next if processed_rule_ids[rid]
224
+
225
+ # Check same media context
226
+ next if rule_to_media[rid] != current_media
227
+
228
+ # Check declarations match (compare arrays directly for performance)
229
+ if declarations_equal?(rule.declarations, other_rule.declarations)
230
+ matching_selectors << other_rule.selector
231
+ processed_rule_ids[rid] = true
232
+ end
233
+ end
234
+
235
+ matching_selectors
236
+ end
237
+
238
+ # Private shared implementation for stylesheet serialization with optional selector list grouping
239
+ # All formatting behavior controlled by kwargs to avoid mode flags and if/else branches
240
+ def self._serialize_stylesheet_with_grouping(
241
+ rules:,
242
+ media_index:,
243
+ result:,
244
+ selector_lists:,
245
+ opening_brace:, # ' { ' (compact) vs " {\n" (formatted)
246
+ closing_brace:, # " }\n" (compact) vs "}\n" (formatted)
247
+ media_indent:, # '' (compact) vs ' ' (formatted)
248
+ decl_indent_base:, # nil (compact) vs ' ' (formatted base rules)
249
+ decl_indent_media:, # nil (compact) vs ' ' (formatted media rules)
250
+ add_blank_lines: # false (compact) vs true (formatted)
251
+ )
252
+ grouping_enabled = selector_lists && !selector_lists.empty?
253
+
254
+ # Build rule_id => media_symbol map
255
+ rule_to_media = {}
256
+ media_index.each do |media_sym, rule_ids|
257
+ rule_ids.each do |rule_id|
258
+ rule_to_media[rule_id] = media_sym
259
+ end
260
+ end
261
+
262
+ # Track processed rules to avoid duplicates when grouping
263
+ processed_rule_ids = {}
264
+
265
+ # Iterate through rules in insertion order, grouping consecutive media queries
266
+ current_media = nil
267
+ in_media_block = false
268
+ rule_index = 0
269
+
270
+ rules.each do |rule|
271
+ # Skip if already processed (when grouped)
272
+ next if processed_rule_ids[rule.id]
273
+
274
+ rule_media = rule_to_media[rule.id]
275
+ is_first_rule = (rule_index == 0)
276
+
277
+ if rule_media.nil?
278
+ # Not in any media query - close any open media block first
279
+ if in_media_block
280
+ result << "}\n"
281
+ in_media_block = false
282
+ current_media = nil
283
+ end
284
+
285
+ # Add blank line prefix for non-first rules (formatted only)
286
+ result << "\n" if add_blank_lines && !is_first_rule
287
+
288
+ # Try to group with other rules from same selector list
289
+ if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
290
+ selectors = find_groupable_selectors(
291
+ rule: rule,
292
+ rules: rules,
293
+ selector_lists: selector_lists,
294
+ processed_rule_ids: processed_rule_ids,
295
+ rule_to_media: rule_to_media,
296
+ current_media: rule_media
297
+ )
298
+
299
+ # Serialize with grouped selectors
300
+ result << selectors.join(', ') << opening_brace
301
+ if decl_indent_base
302
+ serialize_declarations_formatted(result, rule.declarations, decl_indent_base)
303
+ else
304
+ serialize_declarations(result, rule.declarations)
305
+ end
306
+ result << closing_brace
307
+ else
308
+ # Serialize individual rule
309
+ if decl_indent_base
310
+ serialize_rule_formatted(result, rule, '', true)
311
+ else
312
+ serialize_rule(result, rule)
313
+ end
314
+ processed_rule_ids[rule.id] = true
315
+ end
316
+ else
317
+ # This rule is in a media query
318
+ if current_media.nil? || current_media != rule_media
319
+ # Close previous media block if open
320
+ if in_media_block
321
+ result << "}\n"
322
+ end
323
+
324
+ # Add blank line prefix for non-first rules (formatted only)
325
+ result << "\n" if add_blank_lines && !is_first_rule
326
+
327
+ # Open new media block
328
+ current_media = rule_media
329
+ result << "@media #{current_media} {\n"
330
+ in_media_block = true
331
+ end
332
+
333
+ # Try to group with other rules from same selector list
334
+ if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
335
+ selectors = find_groupable_selectors(
336
+ rule: rule,
337
+ rules: rules,
338
+ selector_lists: selector_lists,
339
+ processed_rule_ids: processed_rule_ids,
340
+ rule_to_media: rule_to_media,
341
+ current_media: rule_media
342
+ )
343
+
344
+ # Serialize with grouped selectors (with media indent)
345
+ result << media_indent << selectors.join(', ') << opening_brace
346
+ if decl_indent_media
347
+ serialize_declarations_formatted(result, rule.declarations, decl_indent_media)
348
+ else
349
+ serialize_declarations(result, rule.declarations)
350
+ end
351
+ result << media_indent << closing_brace
352
+ else
353
+ # Serialize individual rule inside media block
354
+ if decl_indent_media
355
+ serialize_rule_formatted(result, rule, media_indent, true)
356
+ else
357
+ serialize_rule(result, rule)
358
+ end
359
+ processed_rule_ids[rule.id] = true
360
+ end
361
+ end
362
+
363
+ rule_index += 1
364
+ end
365
+
366
+ # Close final media block if still open
367
+ if in_media_block
368
+ result << "}\n"
369
+ end
370
+
371
+ result
372
+ end
373
+ private_class_method :_serialize_stylesheet_with_grouping
374
+
375
+ # Helper: check if two declaration arrays are equal
376
+ def self.declarations_equal?(decls1, decls2)
377
+ return false if decls1.size != decls2.size
378
+
379
+ decls1.each_with_index do |d1, i|
380
+ d2 = decls2[i]
381
+ return false if d1.property != d2.property
382
+ return false if d1.value != d2.value
383
+ return false if d1.important != d2.important
384
+ end
385
+
386
+ true
387
+ end
388
+
239
389
  # Helper: serialize a single rule
240
390
  def self.serialize_rule(result, rule)
241
391
  # Check if this is an AtRule
@@ -307,8 +457,9 @@ module Cataract
307
457
  # @param media_index [Hash] Media query symbol => array of rule IDs
308
458
  # @param charset [String, nil] @charset value
309
459
  # @param has_nesting [Boolean] Whether any nested rules exist
460
+ # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
310
461
  # @return [String] Formatted CSS string
311
- def self._stylesheet_to_formatted_s(rules, media_index, charset, has_nesting)
462
+ def self._stylesheet_to_formatted_s(rules, media_index, charset, has_nesting, selector_lists = {})
312
463
  result = +''
313
464
 
314
465
  # Add @charset if present
@@ -318,7 +469,7 @@ module Cataract
318
469
 
319
470
  # Fast path: no nesting - use simple algorithm
320
471
  unless has_nesting
321
- return stylesheet_to_formatted_s_original(rules, media_index, result)
472
+ return stylesheet_to_formatted_s_original(rules, media_index, result, selector_lists)
322
473
  end
323
474
 
324
475
  # Build parent-child relationships
@@ -383,68 +534,19 @@ module Cataract
383
534
  end
384
535
 
385
536
  # Helper: formatted serialization without nesting support
386
- def self.stylesheet_to_formatted_s_original(rules, media_index, result)
387
- # Build rule_id => media_symbol map
388
- rule_to_media = {}
389
- media_index.each do |media_sym, rule_ids|
390
- rule_ids.each do |rule_id|
391
- rule_to_media[rule_id] = media_sym
392
- end
393
- end
394
-
395
- # Iterate through rules, grouping consecutive media queries
396
- current_media = nil
397
- in_media_block = false
398
- rule_index = 0
399
-
400
- rules.each do |rule|
401
- rule_media = rule_to_media[rule.id]
402
- is_first_rule = (rule_index == 0)
403
-
404
- if rule_media.nil?
405
- # Not in any media query - close any open media block first
406
- if in_media_block
407
- result << "}\n"
408
- in_media_block = false
409
- current_media = nil
410
- end
411
-
412
- # Add blank line prefix for non-first rules
413
- result << "\n" unless is_first_rule
414
-
415
- # Output rule with no indentation (always single newline suffix)
416
- serialize_rule_formatted(result, rule, '', true)
417
- else
418
- # This rule is in a media query
419
- if current_media.nil? || current_media != rule_media
420
- # Close previous media block if open
421
- if in_media_block
422
- result << "}\n"
423
- end
424
-
425
- # Add blank line prefix for non-first rules
426
- result << "\n" unless is_first_rule
427
-
428
- # Open new media block
429
- current_media = rule_media
430
- result << "@media #{current_media} {\n"
431
- in_media_block = true
432
- end
433
-
434
- # Serialize rule inside media block with 2-space indentation
435
- # Rules inside media blocks always get single newline (is_last=true)
436
- serialize_rule_formatted(result, rule, ' ', true)
437
- end
438
-
439
- rule_index += 1
440
- end
441
-
442
- # Close final media block if still open
443
- if in_media_block
444
- result << "}\n"
445
- end
446
-
447
- result
537
+ def self.stylesheet_to_formatted_s_original(rules, media_index, result, selector_lists)
538
+ _serialize_stylesheet_with_grouping(
539
+ rules: rules,
540
+ media_index: media_index,
541
+ result: result,
542
+ selector_lists: selector_lists,
543
+ opening_brace: " {\n",
544
+ closing_brace: "}\n",
545
+ media_indent: ' ',
546
+ decl_indent_base: ' ',
547
+ decl_indent_media: ' ',
548
+ add_blank_lines: true
549
+ )
448
550
  end
449
551
 
450
552
  # Helper: serialize a rule with nested children (formatted)
data/lib/cataract/pure.rb CHANGED
@@ -84,14 +84,16 @@ module Cataract
84
84
  #
85
85
  # @api private
86
86
  # @param css_string [String] CSS to parse
87
+ # @param parser_options [Hash] Parser configuration options
88
+ # @option parser_options [Boolean] :selector_lists (true) Track selector lists
87
89
  # @return [Hash] {
88
90
  # rules: Array<Rule>, # Flat array of Rule/AtRule structs
89
91
  # _media_index: Hash, # Symbol => Array of rule IDs
90
92
  # charset: String|nil, # @charset value if present
91
93
  # _has_nesting: Boolean # Whether any nested rules exist
92
94
  # }
93
- def self._parse_css(css_string)
94
- parser = Parser.new(css_string)
95
+ def self._parse_css(css_string, parser_options = {})
96
+ parser = Parser.new(css_string, parser_options: parser_options)
95
97
  parser.parse
96
98
  end
97
99
 
data/lib/cataract/rule.rb CHANGED
@@ -24,16 +24,44 @@ module Cataract
24
24
  # @attr [Integer, nil] specificity CSS specificity value (calculated lazily)
25
25
  # @attr [Integer, nil] parent_rule_id Parent rule ID for nested rules
26
26
  # @attr [Integer, nil] nesting_style 0=implicit, 1=explicit, nil=not nested
27
+ # @attr [Integer, nil] selector_list_id ID linking rules from same selector list (e.g., "h1, h2")
27
28
  Rule = Struct.new(
28
29
  :id,
29
30
  :selector,
30
31
  :declarations,
31
32
  :specificity,
32
33
  :parent_rule_id,
33
- :nesting_style
34
+ :nesting_style,
35
+ :selector_list_id
34
36
  )
35
37
 
36
38
  class Rule
39
+ # Factory method for creating Rules with keyword arguments (for tests/readability).
40
+ # C code and hot paths should use positional arguments directly via Rule.new.
41
+ #
42
+ # @param id [Integer] The rule's position in the stylesheet
43
+ # @param selector [String] CSS selector
44
+ # @param declarations [Array<Declaration>] Array of declarations
45
+ # @param specificity [Integer, nil] Specificity value (nil to calculate lazily)
46
+ # @param parent_rule_id [Integer, nil] Parent rule ID for nested rules
47
+ # @param nesting_style [Integer, nil] Nesting style (0=implicit, 1=explicit, nil=not nested)
48
+ # @param selector_list_id [Integer, nil] Selector list ID for grouping
49
+ # @return [Rule] New rule instance
50
+ #
51
+ # @example Create a rule with keyword arguments
52
+ # Rule.make(
53
+ # id: 0,
54
+ # selector: '.foo',
55
+ # declarations: [Declaration.new('color', 'red', false)],
56
+ # specificity: 10,
57
+ # parent_rule_id: nil,
58
+ # nesting_style: nil,
59
+ # selector_list_id: nil
60
+ # )
61
+ def self.make(id:, selector:, declarations:, specificity: nil, parent_rule_id: nil, nesting_style: nil, selector_list_id: nil)
62
+ new(id, selector, declarations, specificity, parent_rule_id, nesting_style, selector_list_id)
63
+ end
64
+
37
65
  # Silence warning about method redefinition. We redefine below to lazily calculate
38
66
  # specificity
39
67
  undef_method :specificity if method_defined?(:specificity)
@@ -87,6 +115,7 @@ module Cataract
87
115
  #
88
116
  # @param property [String] CSS property name to match
89
117
  # @param value [String, nil] Optional value to match
118
+ # @param prefix_match [Boolean] Whether to match by prefix (default: false)
90
119
  # @return [Boolean] true if rule has matching declaration
91
120
  #
92
121
  # @example Check for color property
@@ -94,9 +123,16 @@ module Cataract
94
123
  #
95
124
  # @example Check for specific property value
96
125
  # rule.has_property?('color', 'red') #=> true
97
- def has_property?(property, value = nil)
126
+ #
127
+ # @example Check for any margin-related property
128
+ # rule.has_property?('margin', prefix_match: true) #=> true if has margin, margin-top, etc.
129
+ def has_property?(property, value = nil, prefix_match: false)
98
130
  declarations.any? do |decl|
99
- property_matches = decl.property == property
131
+ property_matches = if prefix_match
132
+ decl.property.start_with?(property)
133
+ else
134
+ decl.property == property
135
+ end
100
136
  value_matches = value.nil? || decl.value == value
101
137
  property_matches && value_matches
102
138
  end