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.
@@ -4,159 +4,180 @@
4
4
  # NO REGEXP ALLOWED - char-by-char parsing only
5
5
 
6
6
  module Cataract
7
- # Legacy CSS2 pseudo-elements written with a single colon (e.g. :before)
8
- # that must still be counted as pseudo-elements, not pseudo-classes.
9
- PSEUDO_ELEMENT_KEYWORDS = %w[before after first-line first-letter selection].freeze
10
-
11
- # Calculate CSS specificity for a selector
12
- #
13
- # @param selector [String] CSS selector
14
- # @return [Integer] Specificity value
15
- #
16
- # Specificity calculation (per CSS spec):
17
- # - Count IDs (#id) - each worth 100
18
- # - Count classes/attributes/pseudo-classes (.class, [attr], :pseudo) - each worth 10
19
- # - Count elements/pseudo-elements (div, ::before) - each worth 1
20
- def self.calculate_specificity(selector)
21
- return 0 if selector.nil? || selector.empty?
22
-
23
- # Counters for specificity components
24
- id_count = 0
25
- class_count = 0
26
- attr_count = 0
27
- pseudo_class_count = 0
28
- pseudo_element_count = 0
29
- element_count = 0
30
-
31
- i = 0
32
- len = selector.length
33
-
34
- while i < len
35
- byte = selector.getbyte(i)
36
-
37
- if byte == BYTE_HASH
38
- id_count += 1
39
- i = skip_identifier(selector, i + 1, len)
40
- elsif byte == BYTE_DOT
41
- class_count += 1
42
- i = skip_identifier(selector, i + 1, len)
43
- elsif byte == BYTE_LBRACKET
44
- attr_count += 1
45
- i = skip_attribute_selector(selector, i, len)
46
- elsif byte == BYTE_COLON
47
- i, is_not, not_content, counts_as_element = parse_pseudo(selector, i, len)
48
- if not_content
49
- # :not() doesn't count itself, but its content does
50
- not_specificity = calculate_specificity(not_content)
51
- id_count += not_specificity / 100
52
- class_count += (not_specificity % 100) / 10
53
- element_count += not_specificity % 10
54
- elsif !is_not
55
- if counts_as_element
56
- pseudo_element_count += 1
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)
19
+ end
20
+ private :letter?
21
+
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)
57
83
  else
58
- pseudo_class_count += 1
84
+ # Whitespace, combinators, and the universal selector (*) all have
85
+ # zero specificity - just skip a single byte.
86
+ i += 1
59
87
  end
60
88
  end
61
- elsif letter?(byte)
62
- element_count += 1
63
- i = skip_identifier(selector, i + 1, len)
64
- else
65
- # Whitespace, combinators, and the universal selector (*) all have
66
- # zero specificity - just skip a single byte.
67
- i += 1
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)
68
94
  end
69
- end
70
95
 
71
- # Calculate specificity using W3C formula
72
- (id_count * 100) +
73
- ((class_count + attr_count + pseudo_class_count) * 10) +
74
- ((element_count + pseudo_element_count) * 1)
75
- end
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
101
+ end
102
+ private :skip_identifier
76
103
 
77
- # Advances past an identifier (used after #id, .class, element names, and
78
- # pseudo names), returning the index of the first non-identifier byte.
79
- def self.skip_identifier(selector, pos, len)
80
- pos += 1 while pos < len && ident_char?(selector.getbyte(pos))
81
- pos
82
- end
83
- private_class_method :skip_identifier
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
120
+ end
121
+ pos
122
+ end
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
150
+ is_pseudo_element = false
151
+ if pos < len && selector.getbyte(pos) == BYTE_COLON
152
+ is_pseudo_element = true
153
+ pos += 1
154
+ end
84
155
 
85
- # Advances past a bracketed [attr] selector, honoring nested brackets,
86
- # returning the index just after the matching closing bracket.
87
- def self.skip_attribute_selector(selector, pos, len)
88
- skip_balanced(selector, pos + 1, len, BYTE_LBRACKET, BYTE_RBRACKET)
89
- end
90
- private_class_method :skip_attribute_selector
91
-
92
- # Advances past a balanced open/close byte pair (already past the opening
93
- # byte, with depth 1), returning the index just after the matching close.
94
- def self.skip_balanced(selector, pos, len, open_byte, close_byte)
95
- depth = 1
96
- while pos < len && depth > 0
97
- b = selector.getbyte(pos)
98
- depth += 1 if b == open_byte
99
- depth -= 1 if b == close_byte
100
- pos += 1
101
- end
102
- pos
103
- end
104
- private_class_method :skip_balanced
105
-
106
- # Advances past a balanced open/close byte pair (already past the opening
107
- # byte, with depth 1), returning the index OF the matching close byte
108
- # (rather than past it), so the caller can capture the content in between.
109
- def self.find_balanced_close(selector, pos, len, open_byte, close_byte)
110
- depth = 1
111
- while pos < len && depth > 0
112
- b = selector.getbyte(pos)
113
- depth += 1 if b == open_byte
114
- depth -= 1 if b == close_byte
115
- pos += 1 if depth > 0
116
- end
117
- pos
118
- end
119
- private_class_method :find_balanced_close
120
-
121
- # Parses a :pseudo-class, ::pseudo-element, or :not(...) token starting at
122
- # the colon byte. Returns [new_pos, is_not, not_content, counts_as_element]:
123
- # - new_pos: index just after the fully-consumed token (incl. any (...) args)
124
- # - is_not: whether this token is :not (which never counts itself)
125
- # - not_content: the non-empty content of :not(...), or nil otherwise
126
- # - counts_as_element: whether this token counts toward pseudo-elements
127
- # (::foo, or a legacy single-colon pseudo-element like :before) rather
128
- # than pseudo-classes
129
- def self.parse_pseudo(selector, pos, len)
130
- pos += 1
131
- is_pseudo_element = false
132
- if pos < len && selector.getbyte(pos) == BYTE_COLON
133
- is_pseudo_element = true
134
- pos += 1
135
- end
156
+ pseudo_start = pos
157
+ pos = skip_identifier(selector, pos, len)
158
+ pseudo_name = selector[pseudo_start...pos]
159
+
160
+ is_legacy_pseudo_element = !is_pseudo_element && !pseudo_name.empty? &&
161
+ PSEUDO_ELEMENT_KEYWORDS.include?(pseudo_name)
162
+ is_not = (pseudo_name == 'not')
163
+ not_content = nil
164
+
165
+ if pos < len && selector.getbyte(pos) == BYTE_LPAREN
166
+ pos += 1
167
+ if is_not
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
173
+ else
174
+ pos = skip_balanced(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
175
+ end
176
+ end
136
177
 
137
- pseudo_start = pos
138
- pos = skip_identifier(selector, pos, len)
139
- pseudo_name = selector[pseudo_start...pos]
140
-
141
- is_legacy_pseudo_element = !is_pseudo_element && !pseudo_name.empty? &&
142
- PSEUDO_ELEMENT_KEYWORDS.include?(pseudo_name)
143
- is_not = (pseudo_name == 'not')
144
- not_content = nil
145
-
146
- if pos < len && selector.getbyte(pos) == BYTE_LPAREN
147
- pos += 1
148
- if is_not
149
- content_start = pos
150
- pos = find_balanced_close(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
151
- content = selector[content_start...pos]
152
- not_content = content unless content.empty?
153
- pos += 1 # Skip closing paren
154
- else
155
- pos = skip_balanced(selector, pos, len, BYTE_LPAREN, BYTE_RPAREN)
178
+ [pos, is_not, not_content, is_pseudo_element || is_legacy_pseudo_element]
156
179
  end
180
+ private :parse_pseudo
157
181
  end
158
-
159
- [pos, is_not, not_content, is_pseudo_element || is_legacy_pseudo_element]
160
182
  end
161
- private_class_method :parse_pseudo
162
183
  end
data/lib/cataract/pure.rb CHANGED
@@ -7,16 +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
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.
20
28
 
21
29
  require_relative 'error'
22
30
 
@@ -24,7 +32,7 @@ require_relative 'version'
24
32
  require_relative 'constants'
25
33
 
26
34
  # Load struct definitions and supporting files
27
- # (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)
28
36
  require_relative 'declaration'
29
37
  require_relative 'rule'
30
38
  require_relative 'at_rule'
@@ -35,115 +43,79 @@ require_relative 'stylesheet'
35
43
  require_relative 'declarations'
36
44
  require_relative 'import_resolver'
37
45
 
38
- # Add to_s method to Declarations class for pure Ruby mode
39
- module Cataract
40
- class Declarations
41
- # Serialize declarations to CSS string
42
- def to_s
43
- result = String.new
44
- @values.each_with_index do |decl, i|
45
- result << decl.property
46
- result << ': '
47
- result << decl.value
48
- result << ' !important' if decl.important
49
- result << ';'
50
- result << ' ' if i < @values.length - 1 # Add space after semicolon except for last
51
- end
52
- result
53
- end
54
- end
55
- end
56
-
57
46
  # Load pure Ruby implementation modules
58
47
  require_relative 'pure/byte_constants'
59
- require_relative 'pure/helpers'
60
48
  require_relative 'pure/specificity'
61
49
  require_relative 'pure/serializer'
62
50
  require_relative 'pure/parser'
63
51
  require_relative 'pure/flatten'
52
+ require_relative 'pure/declarations'
64
53
 
65
54
  module Cataract
66
- # Flag to indicate pure Ruby version is loaded
67
- PURE_RUBY_LOADED = true
68
-
69
- # Implementation type constant
70
- IMPLEMENTATION = :ruby
71
-
72
- # Compile flags (mimic C version)
73
- COMPILE_FLAGS = {
74
- debug: false,
75
- str_buf_optimization: false,
76
- pure_ruby: true
77
- }.freeze
78
-
79
- # Parse CSS string and return hash with rules, media_index, charset, etc.
80
- #
81
- # @api private
82
- # @param css_string [String] CSS to parse
83
- # @param parser_options [Hash] Parser configuration options
84
- # @option parser_options [Boolean] :selector_lists (true) Track selector lists
85
- # @return [Hash] {
86
- # rules: Array<Rule>, # Flat array of Rule/AtRule structs
87
- # _media_index: Hash, # Symbol => Array of rule IDs
88
- # charset: String|nil, # @charset value if present
89
- # _has_nesting: Boolean # Whether any nested rules exist
90
- # }
91
- def self._parse_css(css_string, parser_options = {})
92
- parser = Parser.new(css_string, parser_options: parser_options)
93
- parser.parse
94
- end
95
-
96
- # NOTE: Copied from cataract.rb
97
- # Need to untangle this eventually
98
- def self.parse_css(css, **options)
99
- Stylesheet.parse(css, **options)
100
- end
101
-
102
- # Flatten stylesheet rules according to CSS cascade rules
103
- #
104
- # @param stylesheet [Stylesheet] Stylesheet to flatten
105
- # @return [Stylesheet] New stylesheet with flattened rules
106
- def self.flatten(stylesheet)
107
- Flatten.flatten(stylesheet, mutate: false)
108
- end
109
-
110
- # Flatten stylesheet rules in-place (mutates receiver)
111
- #
112
- # @param stylesheet [Stylesheet] Stylesheet to flatten
113
- # @return [Stylesheet] Same stylesheet (mutated)
114
- def self.flatten!(stylesheet)
115
- Flatten.flatten(stylesheet, mutate: true)
116
- 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
117
86
 
118
- # Deprecated: Use flatten instead
119
- def self.merge(stylesheet)
120
- warn 'Cataract.merge is deprecated, use Cataract.flatten instead', uplevel: 1
121
- flatten(stylesheet)
122
- 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
123
94
 
124
- # Deprecated: Use flatten! instead
125
- def self.merge!(stylesheet)
126
- warn 'Cataract.merge! is deprecated, use Cataract.flatten! instead', uplevel: 1
127
- flatten!(stylesheet)
128
- 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
129
104
 
130
- # Expand a single shorthand declaration into longhand declarations.
131
- # Underscore prefix indicates semi-private API - use with caution.
132
- #
133
- # @param decl [Declaration] Declaration to expand
134
- # @return [Array<Declaration>] Array of expanded longhand declarations
135
- # @api private
136
- def self.expand_shorthand(decl)
137
- 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
138
111
  end
139
112
 
140
- # Add stub method to Stylesheet for pure Ruby implementation
141
113
  class Stylesheet
142
- # Color conversion is only available in the native C extension
114
+ # Color conversion has no pure-Ruby implementation.
143
115
  #
144
- # @raise [NotImplementedError] Always raises - color conversion requires C extension
116
+ # @raise [NotImplementedError] Always raises - not implemented for pure Ruby
145
117
  def convert_colors!(*_args)
146
- raise NotImplementedError, 'convert_colors! is only available in the native C extension'
118
+ raise NotImplementedError, 'convert_colors! is not yet implemented in Cataract'
147
119
  end
148
120
  end
149
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
@@ -112,6 +112,13 @@ module Cataract
112
112
  # Type validation
113
113
  raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
114
114
 
115
+ # Which backend (Cataract::Backends::Native / ::Pure) produced this
116
+ # stylesheet - defaults to whichever this process picked as active.
117
+ # Not part of the public options contract (used internally by
118
+ # Backends::Native.parse / Backends::Pure.parse), so it's popped off
119
+ # before @options is built.
120
+ @backend = options.delete(:backend) || Cataract::Backends.active
121
+
115
122
  # Support :imports as alias for :import (backwards compatibility)
116
123
  options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
117
124
 
@@ -169,6 +176,7 @@ module Cataract
169
176
  # @param source [Stylesheet] Source stylesheet being copied
170
177
  def initialize_copy(source)
171
178
  super
179
+ @backend = source.backend
172
180
  @options = source.options.dup
173
181
  @rules = source.rules.dup
174
182
  @media_queries = source.media_queries.dup
@@ -466,7 +474,7 @@ module Cataract
466
474
  # @example Filter to multiple media types
467
475
  # sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
468
476
  def to_s(media: :all)
469
- Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
477
+ @backend.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
470
478
  end
471
479
  alias to_css to_s
472
480
 
@@ -492,7 +500,7 @@ module Cataract
492
500
  #
493
501
  # @see #to_s For compact single-line output
494
502
  def to_formatted_s(media: :all)
495
- Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
503
+ @backend.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
496
504
  end
497
505
 
498
506
  # Get number of rules
@@ -718,7 +726,7 @@ module Cataract
718
726
  parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
719
727
 
720
728
  # Parse CSS first (this extracts @import statements into result[:imports])
721
- result = Cataract._parse_css(css, parse_options)
729
+ result = @backend.parse(css, parse_options)
722
730
 
723
731
  merge_parsed_block!(result, effective_base_uri, effective_base_dir)
724
732
 
@@ -794,7 +802,9 @@ module Cataract
794
802
  #
795
803
  # @return [Stylesheet] New stylesheet with cascade applied
796
804
  def flatten
797
- Cataract.flatten(self)
805
+ result = @backend.flatten(self)
806
+ result.backend = @backend
807
+ result
798
808
  end
799
809
  alias cascade flatten
800
810
 
@@ -813,7 +823,7 @@ module Cataract
813
823
  #
814
824
  # @return [self] Returns self for method chaining
815
825
  def flatten!
816
- flattened = Cataract.flatten(self)
826
+ flattened = @backend.flatten(self)
817
827
  @rules = flattened.rules
818
828
  @media_index = flattened.media_index_cache
819
829
  @_has_nesting = flattened.has_nesting
@@ -930,6 +940,7 @@ module Cataract
930
940
  # raw cache these need).
931
941
 
932
942
  attr_reader :options, :parser_options
943
+ attr_accessor :backend
933
944
 
934
945
  def next_media_query_id
935
946
  @_next_media_query_id
@@ -1080,7 +1091,7 @@ module Cataract
1080
1091
  # rules, media queries, selector/media-query lists, and index, then
1081
1092
  # resolve any @import statements it contained.
1082
1093
  #
1083
- # @param result [Hash] Return value of Cataract._parse_css
1094
+ # @param result [Hash] Return value of the backend's parse
1084
1095
  # @return [void]
1085
1096
  def merge_parsed_block!(result, effective_base_uri, effective_base_dir)
1086
1097
  offset = @_last_rule_id || 0
@@ -1263,7 +1274,8 @@ module Cataract
1263
1274
  # Build parse options for imported CSS
1264
1275
  parse_opts = {
1265
1276
  import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1266
- parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
1277
+ parser: @parser_options.dup, # Inherit parent's parser options (including selector_lists)
1278
+ backend: @backend # Imported stylesheets are produced by the same backend as their parent
1267
1279
  }
1268
1280
 
1269
1281
  # If URL conversion is enabled (base_uri present), enable it for imported files too