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
@@ -4,203 +4,180 @@
4
4
  # NO REGEXP ALLOWED - char-by-char parsing only
5
5
 
6
6
  module Cataract
7
- # Calculate CSS specificity for a selector
8
- #
9
- # @param selector [String] CSS selector
10
- # @return [Integer] Specificity value
11
- #
12
- # Specificity calculation (per CSS spec):
13
- # - Count IDs (#id) - each worth 100
14
- # - Count classes/attributes/pseudo-classes (.class, [attr], :pseudo) - each worth 10
15
- # - Count elements/pseudo-elements (div, ::before) - each worth 1
16
- def self.calculate_specificity(selector)
17
- return 0 if selector.nil? || selector.empty?
18
-
19
- # Counters for specificity components
20
- id_count = 0
21
- class_count = 0
22
- attr_count = 0
23
- pseudo_class_count = 0
24
- pseudo_element_count = 0
25
- element_count = 0
26
-
27
- i = 0
28
- len = selector.length
29
-
30
- pseudo_element_kwords = %w[before after first-line first-letter selection]
31
-
32
- while i < len
33
- byte = selector.getbyte(i)
34
-
35
- # Skip whitespace and combinators
36
- if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR ||
37
- byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_COMMA
38
- i += 1
39
- next
7
+ module Backends
8
+ class PureImpl
9
+ # Legacy CSS2 pseudo-elements written with a single colon (e.g. :before)
10
+ # that must still be counted as pseudo-elements, not pseudo-classes.
11
+ PSEUDO_ELEMENT_KEYWORDS = %w[before after first-line first-letter selection].freeze
12
+
13
+ # Check if byte is a letter (a-z, A-Z)
14
+ # @param byte [Integer] Byte value from String#getbyte
15
+ # @return [Boolean] true if letter
16
+ def letter?(byte)
17
+ (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) ||
18
+ (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z)
40
19
  end
20
+ private :letter?
41
21
 
42
- # ID selector: #id
43
- if byte == BYTE_HASH
44
- id_count += 1
45
- i += 1
46
- # Skip the identifier
47
- while i < len && ident_char?(selector.getbyte(i))
48
- i += 1
22
+ # Check if byte is alphanumeric, hyphen, or underscore (CSS identifier char)
23
+ # @param byte [Integer] Byte value from String#getbyte
24
+ # @return [Boolean] true if valid identifier character
25
+ def ident_char?(byte)
26
+ letter?(byte) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9) || byte == BYTE_HYPHEN || byte == BYTE_UNDERSCORE
27
+ end
28
+ private :ident_char?
29
+
30
+ # Calculate CSS specificity for a selector
31
+ #
32
+ # @param selector [String] CSS selector
33
+ # @return [Integer] Specificity value
34
+ #
35
+ # Specificity calculation (per CSS spec):
36
+ # - Count IDs (#id) - each worth 100
37
+ # - Count classes/attributes/pseudo-classes (.class, [attr], :pseudo) - each worth 10
38
+ # - Count elements/pseudo-elements (div, ::before) - each worth 1
39
+ def calculate_specificity(selector)
40
+ return 0 if selector.nil? || selector.empty?
41
+
42
+ # Counters for specificity components
43
+ id_count = 0
44
+ class_count = 0
45
+ attr_count = 0
46
+ pseudo_class_count = 0
47
+ pseudo_element_count = 0
48
+ element_count = 0
49
+
50
+ i = 0
51
+ len = selector.length
52
+
53
+ while i < len
54
+ byte = selector.getbyte(i)
55
+
56
+ if byte == BYTE_HASH
57
+ id_count += 1
58
+ i = skip_identifier(selector, i + 1, len)
59
+ elsif byte == BYTE_DOT
60
+ class_count += 1
61
+ i = skip_identifier(selector, i + 1, len)
62
+ elsif byte == BYTE_LBRACKET
63
+ attr_count += 1
64
+ i = skip_attribute_selector(selector, i, len)
65
+ elsif byte == BYTE_COLON
66
+ i, is_not, not_content, counts_as_element = parse_pseudo(selector, i, len)
67
+ if not_content
68
+ # :not() doesn't count itself, but its content does
69
+ not_specificity = calculate_specificity(not_content)
70
+ id_count += not_specificity / 100
71
+ class_count += (not_specificity % 100) / 10
72
+ element_count += not_specificity % 10
73
+ elsif !is_not
74
+ if counts_as_element
75
+ pseudo_element_count += 1
76
+ else
77
+ pseudo_class_count += 1
78
+ end
79
+ end
80
+ elsif letter?(byte)
81
+ element_count += 1
82
+ i = skip_identifier(selector, i + 1, len)
83
+ else
84
+ # Whitespace, combinators, and the universal selector (*) all have
85
+ # zero specificity - just skip a single byte.
86
+ i += 1
87
+ end
49
88
  end
50
- next
89
+
90
+ # Calculate specificity using W3C formula
91
+ (id_count * 100) +
92
+ ((class_count + attr_count + pseudo_class_count) * 10) +
93
+ ((element_count + pseudo_element_count) * 1)
51
94
  end
52
95
 
53
- # Class selector: .class
54
- if byte == BYTE_DOT
55
- class_count += 1
56
- i += 1
57
- # Skip the identifier
58
- while i < len && ident_char?(selector.getbyte(i))
59
- i += 1
60
- end
61
- next
96
+ # Advances past an identifier (used after #id, .class, element names, and
97
+ # pseudo names), returning the index of the first non-identifier byte.
98
+ def skip_identifier(selector, pos, len)
99
+ pos += 1 while pos < len && ident_char?(selector.getbyte(pos))
100
+ pos
62
101
  end
102
+ private :skip_identifier
63
103
 
64
- # Attribute selector: [attr]
65
- if byte == BYTE_LBRACKET
66
- attr_count += 1
67
- i += 1
68
- # Skip to closing bracket
69
- bracket_depth = 1
70
- while i < len && bracket_depth > 0
71
- b = selector.getbyte(i)
72
- if b == BYTE_LBRACKET
73
- bracket_depth += 1
74
- elsif b == BYTE_RBRACKET
75
- bracket_depth -= 1
76
- end
77
- i += 1
104
+ # Advances past a bracketed [attr] selector, honoring nested brackets,
105
+ # returning the index just after the matching closing bracket.
106
+ def skip_attribute_selector(selector, pos, len)
107
+ skip_balanced(selector, pos + 1, len, BYTE_LBRACKET, BYTE_RBRACKET)
108
+ end
109
+ private :skip_attribute_selector
110
+
111
+ # Advances past a balanced open/close byte pair (already past the opening
112
+ # byte, with depth 1), returning the index just after the matching close.
113
+ def skip_balanced(selector, pos, len, open_byte, close_byte)
114
+ depth = 1
115
+ while pos < len && depth > 0
116
+ b = selector.getbyte(pos)
117
+ depth += 1 if b == open_byte
118
+ depth -= 1 if b == close_byte
119
+ pos += 1
78
120
  end
79
- next
121
+ pos
80
122
  end
81
-
82
- # Pseudo-element (::) or pseudo-class (:)
83
- if byte == BYTE_COLON
84
- i += 1
123
+ private :skip_balanced
124
+
125
+ # Advances past a balanced open/close byte pair (already past the opening
126
+ # byte, with depth 1), returning the index OF the matching close byte
127
+ # (rather than past it), so the caller can capture the content in between.
128
+ def find_balanced_close(selector, pos, len, open_byte, close_byte)
129
+ depth = 1
130
+ while pos < len && depth > 0
131
+ b = selector.getbyte(pos)
132
+ depth += 1 if b == open_byte
133
+ depth -= 1 if b == close_byte
134
+ pos += 1 if depth > 0
135
+ end
136
+ pos
137
+ end
138
+ private :find_balanced_close
139
+
140
+ # Parses a :pseudo-class, ::pseudo-element, or :not(...) token starting at
141
+ # the colon byte. Returns [new_pos, is_not, not_content, counts_as_element]:
142
+ # - new_pos: index just after the fully-consumed token (incl. any (...) args)
143
+ # - is_not: whether this token is :not (which never counts itself)
144
+ # - not_content: the non-empty content of :not(...), or nil otherwise
145
+ # - counts_as_element: whether this token counts toward pseudo-elements
146
+ # (::foo, or a legacy single-colon pseudo-element like :before) rather
147
+ # than pseudo-classes
148
+ def parse_pseudo(selector, pos, len)
149
+ pos += 1
85
150
  is_pseudo_element = false
86
-
87
- # Check for double colon (::)
88
- if i < len && selector.getbyte(i) == BYTE_COLON
151
+ if pos < len && selector.getbyte(pos) == BYTE_COLON
89
152
  is_pseudo_element = true
90
- i += 1
91
- end
92
-
93
- # Extract pseudo name
94
- pseudo_start = i
95
- while i < len && ident_char?(selector.getbyte(i))
96
- i += 1
153
+ pos += 1
97
154
  end
98
- pseudo_name = selector[pseudo_start...i]
99
155
 
100
- # Check for legacy pseudo-elements (single colon but should be double)
101
- is_legacy_pseudo_element = false
102
- if !is_pseudo_element && !pseudo_name.empty?
103
- is_legacy_pseudo_element = pseudo_element_kwords.include?(pseudo_name)
104
- end
156
+ pseudo_start = pos
157
+ pos = skip_identifier(selector, pos, len)
158
+ pseudo_name = selector[pseudo_start...pos]
105
159
 
106
- # Check for :not() - it doesn't count itself, but its content does
160
+ is_legacy_pseudo_element = !is_pseudo_element && !pseudo_name.empty? &&
161
+ PSEUDO_ELEMENT_KEYWORDS.include?(pseudo_name)
107
162
  is_not = (pseudo_name == 'not')
163
+ not_content = nil
108
164
 
109
- # Skip function arguments if present
110
- if i < len && selector.getbyte(i) == BYTE_LPAREN
111
- i += 1
112
- paren_depth = 1
113
-
114
- # If it's :not(), calculate specificity of the content
165
+ if pos < len && selector.getbyte(pos) == BYTE_LPAREN
166
+ pos += 1
115
167
  if is_not
116
- not_content_start = i
117
-
118
- # Find closing paren
119
- while i < len && paren_depth > 0
120
- b = selector.getbyte(i)
121
- if b == BYTE_LPAREN
122
- paren_depth += 1
123
- elsif b == BYTE_RPAREN
124
- paren_depth -= 1
125
- end
126
- i += 1 if paren_depth > 0
127
- end
128
-
129
- not_content = selector[not_content_start...i]
130
-
131
- # Recursively calculate specificity of :not() content
132
- unless not_content.empty?
133
- not_specificity = calculate_specificity(not_content)
134
-
135
- # Add :not() content's specificity to our counts
136
- additional_a = not_specificity / 100
137
- additional_b = (not_specificity % 100) / 10
138
- additional_c = not_specificity % 10
139
-
140
- id_count += additional_a
141
- class_count += additional_b
142
- element_count += additional_c
143
- end
144
-
145
- i += 1 # Skip closing paren
146
- else
147
- # Skip other function arguments
148
- while i < len && paren_depth > 0
149
- b = selector.getbyte(i)
150
- if b == BYTE_LPAREN
151
- paren_depth += 1
152
- elsif b == BYTE_RPAREN
153
- paren_depth -= 1
154
- end
155
- i += 1
156
- end
157
-
158
- # Count the pseudo-class/element
159
- if is_pseudo_element || is_legacy_pseudo_element
160
- pseudo_element_count += 1
161
- else
162
- pseudo_class_count += 1
163
- end
164
- end
165
- else
166
- # No function arguments - count the pseudo-class/element
167
- if is_not
168
- # :not without parens is invalid, but don't count it
169
- elsif is_pseudo_element || is_legacy_pseudo_element
170
- pseudo_element_count += 1
168
+ content_start = pos
169
+ pos = find_balanced_close(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
170
+ content = selector[content_start...pos]
171
+ not_content = content unless content.empty?
172
+ pos += 1 # Skip closing paren
171
173
  else
172
- pseudo_class_count += 1
174
+ pos = skip_balanced(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
173
175
  end
174
176
  end
175
- next
176
- end
177
-
178
- # Universal selector: *
179
- if byte == BYTE_ASTERISK
180
- # Universal selector has specificity 0, don't count
181
- i += 1
182
- next
183
- end
184
177
 
185
- # Type selector (element name): div, span, etc.
186
- if letter?(byte)
187
- element_count += 1
188
- # Skip the identifier
189
- while i < len && ident_char?(selector.getbyte(i))
190
- i += 1
191
- end
192
- next
178
+ [pos, is_not, not_content, is_pseudo_element || is_legacy_pseudo_element]
193
179
  end
194
-
195
- # Unknown character, skip it
196
- i += 1
180
+ private :parse_pseudo
197
181
  end
198
-
199
- # Calculate specificity using W3C formula
200
- specificity = (id_count * 100) +
201
- ((class_count + attr_count + pseudo_class_count) * 10) +
202
- ((element_count + pseudo_element_count) * 1)
203
-
204
- specificity
205
182
  end
206
183
  end
data/lib/cataract/pure.rb CHANGED
@@ -7,23 +7,24 @@
7
7
  # NO REGEXP ALLOWED - consume chars one at a time like the C version.
8
8
  # ==================================================================
9
9
  #
10
- # Load this instead of the C extension with:
11
- # require 'cataract/pure'
10
+ # Load this instead of the C extension by setting CATARACT_PURE before
11
+ # requiring 'cataract' (not by requiring this file directly - lib/cataract.rb
12
+ # is what sets up Cataract::Backends.active and the top-level identity
13
+ # constants like IMPLEMENTATION; this file alone does not):
14
+ # ENV['CATARACT_PURE'] = '1'
15
+ # require 'cataract'
12
16
  #
13
17
  # Or run tests with:
14
18
  # CATARACT_PURE=1 rake test
15
-
16
- # Check if C extension is already loaded
17
- if defined?(Cataract::NATIVE_EXTENSION_LOADED)
18
- raise LoadError, 'Cataract C extension is already loaded. Cannot load pure Ruby version.'
19
- end
20
-
21
- # Define base module and error classes first
22
- module Cataract
23
- class Error < StandardError; end
24
- class DepthError < Error; end
25
- class SizeError < Error; end
26
- end
19
+ #
20
+ # Everything backend-specific here lives under Cataract::Backends::Pure -
21
+ # nothing is defined directly on Cataract itself, so this file can be loaded
22
+ # in the same process as the native backend without either clobbering the
23
+ # other. The one exception is Stylesheet#convert_colors! below: color
24
+ # conversion has no pure-Ruby implementation at all, so the stub simply
25
+ # raises - native's real implementation (ext/cataract_color, loaded
26
+ # separately and only opt-in) defines its own convert_colors! and is
27
+ # unaffected by this file being loaded or not.
27
28
 
28
29
  require_relative 'error'
29
30
 
@@ -31,7 +32,7 @@ require_relative 'version'
31
32
  require_relative 'constants'
32
33
 
33
34
  # Load struct definitions and supporting files
34
- # (These are also loaded by lib/cataract.rb, but we need them here for direct require)
35
+ # (These are also loaded by lib/cataract/native.rb, but we need them here for direct require)
35
36
  require_relative 'declaration'
36
37
  require_relative 'rule'
37
38
  require_relative 'at_rule'
@@ -42,115 +43,79 @@ require_relative 'stylesheet'
42
43
  require_relative 'declarations'
43
44
  require_relative 'import_resolver'
44
45
 
45
- # Add to_s method to Declarations class for pure Ruby mode
46
- module Cataract
47
- class Declarations
48
- # Serialize declarations to CSS string
49
- def to_s
50
- result = String.new
51
- @values.each_with_index do |decl, i|
52
- result << decl.property
53
- result << ': '
54
- result << decl.value
55
- result << ' !important' if decl.important
56
- result << ';'
57
- result << ' ' if i < @values.length - 1 # Add space after semicolon except for last
58
- end
59
- result
60
- end
61
- end
62
- end
63
-
64
46
  # Load pure Ruby implementation modules
65
47
  require_relative 'pure/byte_constants'
66
- require_relative 'pure/helpers'
67
48
  require_relative 'pure/specificity'
68
49
  require_relative 'pure/serializer'
69
50
  require_relative 'pure/parser'
70
51
  require_relative 'pure/flatten'
52
+ require_relative 'pure/declarations'
71
53
 
72
54
  module Cataract
73
- # Flag to indicate pure Ruby version is loaded
74
- PURE_RUBY_LOADED = true
75
-
76
- # Implementation type constant
77
- IMPLEMENTATION = :ruby
78
-
79
- # Compile flags (mimic C version)
80
- COMPILE_FLAGS = {
81
- debug: false,
82
- str_buf_optimization: false,
83
- pure_ruby: true
84
- }.freeze
85
-
86
- # Parse CSS string and return hash with rules, media_index, charset, etc.
87
- #
88
- # @api private
89
- # @param css_string [String] CSS to parse
90
- # @param parser_options [Hash] Parser configuration options
91
- # @option parser_options [Boolean] :selector_lists (true) Track selector lists
92
- # @return [Hash] {
93
- # rules: Array<Rule>, # Flat array of Rule/AtRule structs
94
- # _media_index: Hash, # Symbol => Array of rule IDs
95
- # charset: String|nil, # @charset value if present
96
- # _has_nesting: Boolean # Whether any nested rules exist
97
- # }
98
- def self._parse_css(css_string, parser_options = {})
99
- parser = Parser.new(css_string, parser_options: parser_options)
100
- parser.parse
101
- end
102
-
103
- # NOTE: Copied from cataract.rb
104
- # Need to untangle this eventually
105
- def self.parse_css(css, **options)
106
- Stylesheet.parse(css, **options)
107
- end
108
-
109
- # Flatten stylesheet rules according to CSS cascade rules
110
- #
111
- # @param stylesheet [Stylesheet] Stylesheet to flatten
112
- # @return [Stylesheet] New stylesheet with flattened rules
113
- def self.flatten(stylesheet)
114
- Flatten.flatten(stylesheet, mutate: false)
115
- end
116
-
117
- # Flatten stylesheet rules in-place (mutates receiver)
118
- #
119
- # @param stylesheet [Stylesheet] Stylesheet to flatten
120
- # @return [Stylesheet] Same stylesheet (mutated)
121
- def self.flatten!(stylesheet)
122
- Flatten.flatten(stylesheet, mutate: true)
123
- end
55
+ module Backends
56
+ class PureImpl
57
+ # Flag to indicate the pure Ruby backend is loaded
58
+ PURE_RUBY_LOADED = true
59
+
60
+ # Implementation type constant
61
+ IMPLEMENTATION = :ruby
62
+
63
+ # Compile flags (mimic C version)
64
+ COMPILE_FLAGS = {
65
+ debug: false,
66
+ str_buf_optimization: false,
67
+ pure_ruby: true
68
+ }.freeze
69
+
70
+ # Parse CSS string and return hash with rules, media_index, charset, etc.
71
+ # Called by Stylesheet#add_block - not meant for direct use.
72
+ #
73
+ # @param css_string [String] CSS to parse
74
+ # @param parser_options [Hash] Parser configuration options
75
+ # @option parser_options [Boolean] :selector_lists (true) Track selector lists
76
+ # @return [Hash] {
77
+ # rules: Array<Rule>, # Flat array of Rule/AtRule structs
78
+ # _media_index: Hash, # Symbol => Array of rule IDs
79
+ # charset: String|nil, # @charset value if present
80
+ # _has_nesting: Boolean # Whether any nested rules exist
81
+ # }
82
+ def parse(css_string, parser_options = {})
83
+ parser = Parser.new(css_string, parser_options: parser_options)
84
+ parser.parse
85
+ end
124
86
 
125
- # Deprecated: Use flatten instead
126
- def self.merge(stylesheet)
127
- warn 'Cataract.merge is deprecated, use Cataract.flatten instead', uplevel: 1
128
- flatten(stylesheet)
129
- end
87
+ # Flatten stylesheet rules according to CSS cascade rules
88
+ #
89
+ # @param stylesheet [Stylesheet] Stylesheet to flatten
90
+ # @return [Stylesheet] New stylesheet with flattened rules
91
+ def flatten(stylesheet)
92
+ Flatten.flatten(stylesheet, mutate: false)
93
+ end
130
94
 
131
- # Deprecated: Use flatten! instead
132
- def self.merge!(stylesheet)
133
- warn 'Cataract.merge! is deprecated, use Cataract.flatten! instead', uplevel: 1
134
- flatten!(stylesheet)
135
- end
95
+ # Expand a single shorthand declaration into longhand declarations.
96
+ # Called by Rule#expanded_declarations - not meant for direct use.
97
+ #
98
+ # @param decl [Declaration] Declaration to expand
99
+ # @return [Array<Declaration>] Array of expanded longhand declarations
100
+ def expand_shorthand(decl)
101
+ Flatten.expand_shorthand(decl)
102
+ end
103
+ end
136
104
 
137
- # Expand a single shorthand declaration into longhand declarations.
138
- # Underscore prefix indicates semi-private API - use with caution.
139
- #
140
- # @param decl [Declaration] Declaration to expand
141
- # @return [Array<Declaration>] Array of expanded longhand declarations
142
- # @api private
143
- def self.expand_shorthand(decl)
144
- Flatten.expand_shorthand(decl)
105
+ # The active-facing constant is a single frozen instance, not the class
106
+ # itself - none of PureImpl's methods touch instance state, so one shared
107
+ # instance is exactly as safe as the bare module this replaces, while
108
+ # giving its methods genuine instance-level `private` instead of
109
+ # `private_class_method`.
110
+ Pure = PureImpl.new.freeze
145
111
  end
146
112
 
147
- # Add stub method to Stylesheet for pure Ruby implementation
148
113
  class Stylesheet
149
- # Color conversion is only available in the native C extension
114
+ # Color conversion has no pure-Ruby implementation.
150
115
  #
151
- # @raise [NotImplementedError] Always raises - color conversion requires C extension
116
+ # @raise [NotImplementedError] Always raises - not implemented for pure Ruby
152
117
  def convert_colors!(*_args)
153
- raise NotImplementedError, 'convert_colors! is only available in the native C extension'
118
+ raise NotImplementedError, 'convert_colors! is not yet implemented in Cataract'
154
119
  end
155
120
  end
156
121
  end
data/lib/cataract/rule.rb CHANGED
@@ -87,8 +87,11 @@ module Cataract
87
87
  def specificity
88
88
  return self[:specificity] unless self[:specificity].nil?
89
89
 
90
- # Calculate and cache
91
- calculated = Cataract.calculate_specificity(selector)
90
+ # Calculate and cache. Specificity is a pure function of the selector
91
+ # string, identical across backends by design, so this always goes
92
+ # through the process's active backend regardless of which backend
93
+ # actually produced this Rule.
94
+ calculated = Cataract::Backends.active.calculate_specificity(selector)
92
95
  self[:specificity] = calculated
93
96
  calculated
94
97
  end
@@ -215,7 +218,7 @@ module Cataract
215
218
  # rubocop:disable Naming/MemoizedInstanceVariableName
216
219
  def expanded_declarations
217
220
  @_expanded_declarations ||= begin
218
- expanded = declarations.flat_map { |decl| Cataract.expand_shorthand(decl) }
221
+ expanded = declarations.flat_map { |decl| Cataract::Backends.active.expand_shorthand(decl) }
219
222
  expanded.sort_by! { |d| [d.property, d.value, d.important ? 1 : 0] }
220
223
  expanded
221
224
  end