cataract 0.1.4 → 0.2.1

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,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Pure Ruby CSS merge implementation
3
+ # Pure Ruby CSS flatten implementation
4
4
  # NO REGEXP ALLOWED - use string manipulation only
5
5
 
6
6
  module Cataract
7
- module Merge
7
+ module Flatten
8
8
  # Property name constants (US-ASCII for merge output)
9
9
  PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
10
10
  PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
@@ -138,7 +138,7 @@ module Cataract
138
138
  # @param stylesheet [Stylesheet] Stylesheet to merge
139
139
  # @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
140
140
  # @return [Stylesheet] Merged stylesheet
141
- def self.merge(stylesheet, mutate: false)
141
+ def self.flatten(stylesheet, mutate: false)
142
142
  # Separate AtRules (pass-through) from regular Rules (to merge)
143
143
  at_rules = []
144
144
  regular_rules = []
@@ -152,7 +152,9 @@ module Cataract
152
152
  end
153
153
 
154
154
  # Expand shorthands in regular rules only (AtRules don't have declarations)
155
- regular_rules.each { |rule| expand_shorthands!(rule) }
155
+ regular_rules.each do |rule|
156
+ rule.declarations.replace(rule.declarations.flat_map { |decl| _expand_shorthand(decl) })
157
+ end
156
158
 
157
159
  merged_rules = []
158
160
 
@@ -160,7 +162,7 @@ module Cataract
160
162
  # (Nesting is flattened during parsing, so we just merge by resolved selector)
161
163
  grouped = regular_rules.group_by(&:selector)
162
164
  grouped.each do |selector, rules|
163
- merged_rule = merge_rules_for_selector(selector, rules)
165
+ merged_rule = flatten_rules_for_selector(selector, rules)
164
166
  merged_rules << merged_rule if merged_rule
165
167
  end
166
168
 
@@ -195,7 +197,7 @@ module Cataract
195
197
  # @param selector [String] The selector
196
198
  # @param rules [Array<Rule>] Rules with this selector
197
199
  # @return [Rule] Merged rule with cascaded declarations
198
- def self.merge_rules_for_selector(selector, rules)
200
+ def self.flatten_rules_for_selector(selector, rules)
199
201
  # Build declaration map: property => [source_order, specificity, important, value]
200
202
  decl_map = {}
201
203
 
@@ -284,42 +286,39 @@ module Cataract
284
286
  Cataract.calculate_specificity(selector)
285
287
  end
286
288
 
287
- # Expand shorthand properties in a rule (mutates declarations)
289
+ # Expand a single shorthand declaration into longhand declarations.
290
+ # Returns an array of longhand declarations. If the declaration is not a shorthand,
291
+ # returns an array with just that declaration.
288
292
  #
289
- # @param rule [Rule] Rule to expand
290
- def self.expand_shorthands!(rule)
291
- expanded = []
292
-
293
- rule.declarations.each do |decl|
294
- prop = decl.property
295
-
296
- case prop
297
- when 'margin'
298
- expanded.concat(expand_margin(decl))
299
- when 'padding'
300
- expanded.concat(expand_padding(decl))
301
- when 'border'
302
- expanded.concat(expand_border(decl))
303
- when 'border-top', 'border-right', 'border-bottom', 'border-left'
304
- expanded.concat(expand_border_side(decl))
305
- when 'border-width'
306
- expanded.concat(expand_border_width(decl))
307
- when 'border-style'
308
- expanded.concat(expand_border_style(decl))
309
- when 'border-color'
310
- expanded.concat(expand_border_color(decl))
311
- when 'font'
312
- expanded.concat(expand_font(decl))
313
- when 'background'
314
- expanded.concat(expand_background(decl))
315
- when 'list-style'
316
- expanded.concat(expand_list_style(decl))
317
- else
318
- expanded << decl
319
- end
293
+ # @param decl [Declaration] Declaration to expand
294
+ # @return [Array<Declaration>] Array of expanded longhand declarations
295
+ # @api private
296
+ def self._expand_shorthand(decl)
297
+ case decl.property
298
+ when 'margin'
299
+ expand_margin(decl)
300
+ when 'padding'
301
+ expand_padding(decl)
302
+ when 'border'
303
+ expand_border(decl)
304
+ when 'border-top', 'border-right', 'border-bottom', 'border-left'
305
+ expand_border_side(decl)
306
+ when 'border-width'
307
+ expand_border_width(decl)
308
+ when 'border-style'
309
+ expand_border_style(decl)
310
+ when 'border-color'
311
+ expand_border_color(decl)
312
+ when 'font'
313
+ expand_font(decl)
314
+ when 'background'
315
+ expand_background(decl)
316
+ when 'list-style'
317
+ expand_list_style(decl)
318
+ else
319
+ # Not a shorthand, return as-is in an array
320
+ [decl]
320
321
  end
321
-
322
- rule.declarations.replace(expanded)
323
322
  end
324
323
 
325
324
  # Expand margin shorthand
@@ -155,7 +155,20 @@ module Cataract
155
155
  full_match = css_string[import_start...import_end]
156
156
 
157
157
  imports << { url: url, media: media, full_match: full_match }
158
+ elsif match_ascii_ci?(css_string, i, '@charset')
159
+ # Skip @charset if present - it can come before @import
160
+ while i < len && css_string.getbyte(i) != BYTE_SEMICOLON
161
+ i += 1
162
+ end
163
+ i += 1 if i < len # Skip semicolon
158
164
  else
165
+ # If we hit any other content (rules, other at-rules), stop scanning
166
+ # Per CSS spec, @import must be at the top (only @charset can come before)
167
+ byte = css_string.getbyte(i) if i < len
168
+ if i < len && !is_whitespace?(byte)
169
+ break
170
+ end
171
+
159
172
  i += 1
160
173
  end
161
174
  end
@@ -74,6 +74,7 @@ module Cataract
74
74
  # Parser state
75
75
  @rules = [] # Flat array of Rule structs
76
76
  @_media_index = {} # Symbol => Array of rule IDs
77
+ @imports = [] # Array of ImportStatement structs
77
78
  @rule_id_counter = 0 # Next rule ID (0-indexed)
78
79
  @media_query_count = 0 # Safety limit
79
80
  @_has_nesting = false # Set to true if any nested rules found
@@ -82,9 +83,8 @@ module Cataract
82
83
  end
83
84
 
84
85
  def parse
85
- # Skip @import statements at the beginning (they're handled by ImportResolver)
86
- # Per CSS spec, @import must come before all rules (except @charset)
87
- skip_imports
86
+ # @import statements are now handled in parse_at_rule
87
+ # They must come before all rules (except @charset) per CSS spec
88
88
 
89
89
  # Main parsing loop - char-by-char, NO REGEXP
90
90
  until eof?
@@ -182,6 +182,7 @@ module Cataract
182
182
  {
183
183
  rules: @rules,
184
184
  _media_index: @_media_index,
185
+ imports: @imports,
185
186
  charset: @charset,
186
187
  _has_nesting: @_has_nesting
187
188
  }
@@ -658,6 +659,23 @@ module Cataract
658
659
  return
659
660
  end
660
661
 
662
+ # Handle @import - must come before rules (except @charset)
663
+ if at_rule_name == 'import'
664
+ # If we've already seen a rule, this @import is invalid
665
+ if @rules.size > 0
666
+ warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
667
+ # Skip to semicolon
668
+ while !eof? && peek_byte != BYTE_SEMICOLON
669
+ @pos += 1
670
+ end
671
+ @pos += 1 if peek_byte == BYTE_SEMICOLON
672
+ return
673
+ end
674
+
675
+ parse_import_statement
676
+ return
677
+ end
678
+
661
679
  # Handle conditional group at-rules: @supports, @layer, @container, @scope
662
680
  # These behave like @media but don't affect media context
663
681
  if AT_RULE_TYPES.include?(at_rule_name)
@@ -1123,7 +1141,93 @@ module Cataract
1123
1141
  @pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1124
1142
  end
1125
1143
 
1126
- # Skip @import statements at the beginning of CSS
1144
+ # Parse an @import statement
1145
+ # @import "url" [media-query];
1146
+ # @import url("url") [media-query];
1147
+ def parse_import_statement
1148
+ skip_ws_and_comments
1149
+
1150
+ # Check for optional url(
1151
+ has_url_function = false
1152
+ if @pos + 4 <= @len && match_ascii_ci?(@css, @pos, 'url(')
1153
+ has_url_function = true
1154
+ @pos += 4
1155
+ skip_ws_and_comments
1156
+ end
1157
+
1158
+ # Find opening quote
1159
+ byte = peek_byte
1160
+ if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
1161
+ # Invalid @import, skip to semicolon
1162
+ while !eof? && peek_byte != BYTE_SEMICOLON
1163
+ @pos += 1
1164
+ end
1165
+ @pos += 1 unless eof?
1166
+ return
1167
+ end
1168
+
1169
+ quote_char = byte
1170
+ @pos += 1 # Skip opening quote
1171
+
1172
+ url_start = @pos
1173
+
1174
+ # Find closing quote (handle escaped quotes)
1175
+ while !eof? && peek_byte != quote_char
1176
+ @pos += if peek_byte == BYTE_BACKSLASH && @pos + 1 < @len
1177
+ 2 # Skip escaped character
1178
+ else
1179
+ 1
1180
+ end
1181
+ end
1182
+
1183
+ if eof?
1184
+ # Unterminated string
1185
+ return
1186
+ end
1187
+
1188
+ url = byteslice_encoded(url_start, @pos - url_start)
1189
+ @pos += 1 # Skip closing quote
1190
+
1191
+ # Skip closing paren if we had url(
1192
+ if has_url_function
1193
+ skip_ws_and_comments
1194
+ @pos += 1 if peek_byte == BYTE_RPAREN
1195
+ end
1196
+
1197
+ skip_ws_and_comments
1198
+
1199
+ # Check for optional media query (everything until semicolon)
1200
+ media = nil
1201
+ if !eof? && peek_byte != BYTE_SEMICOLON
1202
+ media_start = @pos
1203
+
1204
+ # Find semicolon
1205
+ while !eof? && peek_byte != BYTE_SEMICOLON
1206
+ @pos += 1
1207
+ end
1208
+
1209
+ media_end = @pos
1210
+
1211
+ # Trim trailing whitespace from media query
1212
+ while media_end > media_start && whitespace?(@css.getbyte(media_end - 1))
1213
+ media_end -= 1
1214
+ end
1215
+
1216
+ if media_end > media_start
1217
+ media = byteslice_encoded(media_start, media_end - media_start).to_sym
1218
+ end
1219
+ end
1220
+
1221
+ # Skip semicolon
1222
+ @pos += 1 if peek_byte == BYTE_SEMICOLON
1223
+
1224
+ # Create ImportStatement (resolved: false by default)
1225
+ import_stmt = ImportStatement.new(@rule_id_counter, url, media, false)
1226
+ @imports << import_stmt
1227
+ @rule_id_counter += 1
1228
+ end
1229
+
1230
+ # Skip @import statements at the beginning of CSS (DEPRECATED - now parsed)
1127
1231
  # Per CSS spec, @import must come before all rules (except @charset)
1128
1232
  def skip_imports
1129
1233
  until eof?
@@ -355,6 +355,9 @@ module Cataract
355
355
  current_media = nil
356
356
  end
357
357
 
358
+ # Add blank line before base rule if we just closed a media block (ends with "}\n")
359
+ result << "\n" if result.length > 1 && result.getbyte(-1) == BYTE_NEWLINE && result.getbyte(-2) == BYTE_RBRACE
360
+
358
361
  serialize_rule_with_nesting_formatted(result, rule, rule_children, rule_to_media, '')
359
362
  else
360
363
  if current_media.nil? || current_media != rule_media
data/lib/cataract/pure.rb CHANGED
@@ -32,6 +32,7 @@ require_relative 'version'
32
32
  require_relative 'declaration'
33
33
  require_relative 'rule'
34
34
  require_relative 'at_rule'
35
+ require_relative 'import_statement'
35
36
  require_relative 'stylesheet_scope'
36
37
  require_relative 'stylesheet'
37
38
  require_relative 'declarations'
@@ -63,7 +64,7 @@ require_relative 'pure/specificity'
63
64
  require_relative 'pure/imports'
64
65
  require_relative 'pure/serializer'
65
66
  require_relative 'pure/parser'
66
- require_relative 'pure/merge'
67
+ require_relative 'pure/flatten'
67
68
 
68
69
  module Cataract
69
70
  # Flag to indicate pure Ruby version is loaded
@@ -102,20 +103,42 @@ module Cataract
102
103
  Stylesheet.parse(css)
103
104
  end
104
105
 
105
- # Merge stylesheet rules according to CSS cascade rules
106
+ # Flatten stylesheet rules according to CSS cascade rules
106
107
  #
107
- # @param stylesheet [Stylesheet] Stylesheet to merge
108
- # @return [Stylesheet] New stylesheet with merged rules
109
- def self.merge(stylesheet)
110
- Merge.merge(stylesheet, mutate: false)
108
+ # @param stylesheet [Stylesheet] Stylesheet to flatten
109
+ # @return [Stylesheet] New stylesheet with flattened rules
110
+ def self.flatten(stylesheet)
111
+ Flatten.flatten(stylesheet, mutate: false)
111
112
  end
112
113
 
113
- # Merge stylesheet rules in-place (mutates receiver)
114
+ # Flatten stylesheet rules in-place (mutates receiver)
114
115
  #
115
- # @param stylesheet [Stylesheet] Stylesheet to merge
116
+ # @param stylesheet [Stylesheet] Stylesheet to flatten
116
117
  # @return [Stylesheet] Same stylesheet (mutated)
118
+ def self.flatten!(stylesheet)
119
+ Flatten.flatten(stylesheet, mutate: true)
120
+ end
121
+
122
+ # Deprecated: Use flatten instead
123
+ def self.merge(stylesheet)
124
+ warn 'Cataract.merge is deprecated, use Cataract.flatten instead', uplevel: 1
125
+ flatten(stylesheet)
126
+ end
127
+
128
+ # Deprecated: Use flatten! instead
117
129
  def self.merge!(stylesheet)
118
- Merge.merge(stylesheet, mutate: true)
130
+ warn 'Cataract.merge! is deprecated, use Cataract.flatten! instead', uplevel: 1
131
+ flatten!(stylesheet)
132
+ end
133
+
134
+ # Expand a single shorthand declaration into longhand declarations.
135
+ # Underscore prefix indicates semi-private API - use with caution.
136
+ #
137
+ # @param decl [Declaration] Declaration to expand
138
+ # @return [Array<Declaration>] Array of expanded longhand declarations
139
+ # @api private
140
+ def self._expand_shorthand(decl)
141
+ Flatten._expand_shorthand(decl)
119
142
  end
120
143
 
121
144
  # Add stub method to Stylesheet for pure Ruby implementation
data/lib/cataract/rule.rb CHANGED
@@ -123,18 +123,63 @@ module Cataract
123
123
  # Compare rules for logical equality based on CSS semantics.
124
124
  #
125
125
  # Two rules are equal if they have the same selector and declarations.
126
+ # Shorthand properties are expanded before comparison, so
127
+ # `margin: 10px` equals `margin-top: 10px; margin-right: 10px; ...`
128
+ #
126
129
  # Internal implementation details (id, specificity) are not considered
127
- # since they don't affect the CSS semantics. Specificity is derived from
128
- # the selector, so if selectors match, specificity must match too.
130
+ # since they don't affect the CSS semantics.
131
+ #
132
+ # Can also compare against a CSS string, which is parsed and compared.
129
133
  #
130
- # @param other [Object] Object to compare with
134
+ # @param other [Object] Object to compare with (Rule or String)
131
135
  # @return [Boolean] true if rules have same selector and declarations
132
136
  def ==(other)
133
- return false unless other.is_a?(Rule)
137
+ case other
138
+ when Rule
139
+ return false unless selector == other.selector
134
140
 
135
- selector == other.selector &&
136
- declarations == other.declarations
141
+ expanded_declarations == other.expanded_declarations
142
+ when String
143
+ # Parse CSS string and compare to first rule
144
+ parsed = Cataract.parse_css(other)
145
+ return false unless parsed.rules.size == 1
146
+
147
+ self == parsed.rules.first
148
+ else
149
+ false
150
+ end
137
151
  end
138
152
  alias eql? ==
153
+
154
+ # Generate hash code for this rule.
155
+ #
156
+ # Hash is based on selector and expanded declarations to match the
157
+ # equality semantics. This allows rules to be used as Hash keys or
158
+ # in Sets correctly.
159
+ #
160
+ # @return [Integer] hash code
161
+ # rubocop:disable Naming/MemoizedInstanceVariableName
162
+ def hash
163
+ @_hash ||= [self.class, selector, expanded_declarations].hash
164
+ end
165
+ # rubocop:enable Naming/MemoizedInstanceVariableName
166
+
167
+ protected
168
+
169
+ # Get expanded and normalized declarations for this rule.
170
+ #
171
+ # Shorthands are expanded into their longhand equivalents and sorted
172
+ # to enable semantic comparison. Result is cached.
173
+ #
174
+ # @return [Array<Declaration>] expanded declarations
175
+ # rubocop:disable Naming/MemoizedInstanceVariableName
176
+ def expanded_declarations
177
+ @_expanded_declarations ||= begin
178
+ expanded = declarations.flat_map { |decl| Cataract._expand_shorthand(decl) }
179
+ expanded.sort_by! { |d| [d.property, d.value, d.important ? 1 : 0] }
180
+ expanded
181
+ end
182
+ end
183
+ # rubocop:enable Naming/MemoizedInstanceVariableName
139
184
  end
140
185
  end