cataract 0.2.3 → 0.2.5
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -3
- data/BENCHMARKS.md +50 -32
- data/CHANGELOG.md +21 -1
- data/Gemfile +3 -0
- data/ext/cataract/cataract.c +219 -112
- data/ext/cataract/cataract.h +5 -1
- data/ext/cataract/css_parser.c +875 -50
- data/ext/cataract/flatten.c +233 -91
- data/ext/cataract/shorthand_expander.c +7 -0
- data/lib/cataract/at_rule.rb +2 -1
- data/lib/cataract/constants.rb +10 -0
- data/lib/cataract/error.rb +49 -0
- data/lib/cataract/import_resolver.rb +18 -87
- data/lib/cataract/import_statement.rb +29 -5
- data/lib/cataract/media_query.rb +98 -0
- data/lib/cataract/pure/byte_constants.rb +15 -0
- data/lib/cataract/pure/flatten.rb +127 -15
- data/lib/cataract/pure/parser.rb +800 -271
- data/lib/cataract/pure/serializer.rb +216 -115
- data/lib/cataract/pure.rb +8 -7
- data/lib/cataract/rule.rb +9 -5
- data/lib/cataract/stylesheet.rb +345 -101
- data/lib/cataract/stylesheet_scope.rb +10 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +5 -8
- data/lib/tasks/profile.rake +210 -0
- metadata +5 -2
- data/lib/cataract/pure/imports.rb +0 -268
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -29,15 +29,13 @@ module Cataract
|
|
|
29
29
|
|
|
30
30
|
AT_RULE_TYPES = %w[supports layer container scope].freeze
|
|
31
31
|
|
|
32
|
-
attr_reader :css, :pos, :len
|
|
33
|
-
|
|
34
32
|
# Extract substring and force specified encoding
|
|
35
33
|
# Per CSS spec, charset detection happens at byte-stream level before parsing.
|
|
36
34
|
# All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
|
|
37
35
|
# This prevents ArgumentError on broken/invalid encodings when calling string methods.
|
|
38
36
|
# Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
|
|
39
37
|
def byteslice_encoded(start, length, encoding: 'UTF-8')
|
|
40
|
-
@
|
|
38
|
+
@_css.byteslice(start, length).force_encoding(encoding)
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
# Helper: Case-insensitive ASCII byte comparison
|
|
@@ -65,31 +63,79 @@ module Cataract
|
|
|
65
63
|
true
|
|
66
64
|
end
|
|
67
65
|
|
|
68
|
-
def initialize(css_string, parser_options: {}, parent_media_sym: nil, depth: 0)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@
|
|
76
|
-
|
|
66
|
+
def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
|
|
67
|
+
# Type validation
|
|
68
|
+
raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
|
|
69
|
+
|
|
70
|
+
# Private: Internal parsing state
|
|
71
|
+
@_css = css_string.dup.freeze
|
|
72
|
+
@_pos = 0
|
|
73
|
+
@_len = @_css.bytesize
|
|
74
|
+
@_parent_media_sym = parent_media_sym
|
|
75
|
+
@_parent_media_query_id = parent_media_query_id
|
|
76
|
+
@_depth = depth # Current recursion depth (passed from parent parser)
|
|
77
|
+
|
|
78
|
+
# Private: Parser options with defaults
|
|
79
|
+
@_parser_options = {
|
|
80
|
+
selector_lists: true,
|
|
81
|
+
base_uri: nil,
|
|
82
|
+
absolute_paths: false,
|
|
83
|
+
uri_resolver: nil,
|
|
84
|
+
raise_parse_errors: false
|
|
77
85
|
}.merge(parser_options)
|
|
78
86
|
|
|
79
|
-
# Extract
|
|
80
|
-
@
|
|
87
|
+
# Private: Extract options to ivars to avoid repeated hash lookups in hot path
|
|
88
|
+
@_selector_lists_enabled = @_parser_options[:selector_lists]
|
|
89
|
+
@_base_uri = @_parser_options[:base_uri]
|
|
90
|
+
@_absolute_paths = @_parser_options[:absolute_paths]
|
|
91
|
+
@_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
92
|
+
|
|
93
|
+
# Parse error handling options - extract to ivars for hot path performance
|
|
94
|
+
@_raise_parse_errors = @_parser_options[:raise_parse_errors]
|
|
95
|
+
if @_raise_parse_errors.is_a?(Hash)
|
|
96
|
+
# Granular control - default all to false (opt-in)
|
|
97
|
+
@_check_empty_values = @_raise_parse_errors[:empty_values] || false
|
|
98
|
+
@_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
|
|
99
|
+
@_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
|
|
100
|
+
@_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
|
|
101
|
+
@_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
|
|
102
|
+
@_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
|
|
103
|
+
elsif @_raise_parse_errors == true
|
|
104
|
+
# Enable all error checks
|
|
105
|
+
@_check_empty_values = true
|
|
106
|
+
@_check_malformed_declarations = true
|
|
107
|
+
@_check_invalid_selectors = true
|
|
108
|
+
@_check_invalid_selector_syntax = true
|
|
109
|
+
@_check_malformed_at_rules = true
|
|
110
|
+
@_check_unclosed_blocks = true
|
|
111
|
+
else
|
|
112
|
+
# Disabled
|
|
113
|
+
@_check_empty_values = false
|
|
114
|
+
@_check_malformed_declarations = false
|
|
115
|
+
@_check_invalid_selectors = false
|
|
116
|
+
@_check_invalid_selector_syntax = false
|
|
117
|
+
@_check_malformed_at_rules = false
|
|
118
|
+
@_check_unclosed_blocks = false
|
|
119
|
+
end
|
|
81
120
|
|
|
82
|
-
#
|
|
83
|
-
@
|
|
84
|
-
@_media_index = {} # Symbol => Array of rule IDs
|
|
85
|
-
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
121
|
+
# Private: Internal counters
|
|
122
|
+
@_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
|
|
86
123
|
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
124
|
+
@_next_media_query_list_id = 0 # Counter for media query list IDs
|
|
125
|
+
@_rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
126
|
+
@_media_query_count = 0 # Safety limit
|
|
127
|
+
|
|
128
|
+
# Public: Parser results (returned in parse result hash)
|
|
129
|
+
@rules = [] # Flat array of Rule structs
|
|
130
|
+
@media_queries = [] # Array of MediaQuery objects
|
|
131
|
+
@media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
|
|
87
132
|
@imports = [] # Array of ImportStatement structs
|
|
88
|
-
@rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
89
|
-
@media_query_count = 0 # Safety limit
|
|
90
|
-
@_has_nesting = false # Set to true if any nested rules found
|
|
91
|
-
@depth = depth # Current recursion depth (passed from parent parser)
|
|
92
133
|
@charset = nil # @charset declaration
|
|
134
|
+
|
|
135
|
+
# Semi-private: Internal state exposed with _ prefix in result
|
|
136
|
+
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
137
|
+
@_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
|
|
138
|
+
@_has_nesting = false # Set to true if any nested rules found
|
|
93
139
|
end
|
|
94
140
|
|
|
95
141
|
def parse
|
|
@@ -118,7 +164,7 @@ module Cataract
|
|
|
118
164
|
end
|
|
119
165
|
|
|
120
166
|
# Find the block boundaries
|
|
121
|
-
decl_start = @
|
|
167
|
+
decl_start = @_pos # Should be right after the {
|
|
122
168
|
decl_end = find_matching_brace(decl_start)
|
|
123
169
|
|
|
124
170
|
# Check if block has nested selectors
|
|
@@ -129,21 +175,28 @@ module Cataract
|
|
|
129
175
|
|
|
130
176
|
selectors.each do |individual_selector|
|
|
131
177
|
individual_selector.strip!
|
|
178
|
+
|
|
179
|
+
# Check for empty selector in comma-separated list
|
|
180
|
+
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
181
|
+
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
182
|
+
css: @_css, pos: decl_start, type: :invalid_selector_syntax)
|
|
183
|
+
end
|
|
184
|
+
|
|
132
185
|
next if individual_selector.empty?
|
|
133
186
|
|
|
134
187
|
# Get rule ID for this selector
|
|
135
|
-
current_rule_id = @
|
|
136
|
-
@
|
|
188
|
+
current_rule_id = @_rule_id_counter
|
|
189
|
+
@_rule_id_counter += 1
|
|
137
190
|
|
|
138
191
|
# Reserve parent's position in rules array (ensures parent comes before nested)
|
|
139
192
|
parent_position = @rules.length
|
|
140
193
|
@rules << nil # Placeholder
|
|
141
194
|
|
|
142
195
|
# Parse mixed block (declarations + nested selectors)
|
|
143
|
-
@
|
|
196
|
+
@_depth += 1
|
|
144
197
|
parent_declarations = parse_mixed_block(decl_start, decl_end,
|
|
145
|
-
individual_selector, current_rule_id, @
|
|
146
|
-
@
|
|
198
|
+
individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
|
|
199
|
+
@_depth -= 1
|
|
147
200
|
|
|
148
201
|
# Create parent rule and replace placeholder
|
|
149
202
|
rule = Rule.new(
|
|
@@ -156,16 +209,14 @@ module Cataract
|
|
|
156
209
|
)
|
|
157
210
|
|
|
158
211
|
@rules[parent_position] = rule
|
|
159
|
-
@_media_index[@parent_media_sym] ||= [] if @parent_media_sym
|
|
160
|
-
@_media_index[@parent_media_sym] << current_rule_id if @parent_media_sym
|
|
161
212
|
end
|
|
162
213
|
|
|
163
214
|
# Move position past the closing brace
|
|
164
|
-
@
|
|
165
|
-
@
|
|
215
|
+
@_pos = decl_end
|
|
216
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
166
217
|
else
|
|
167
218
|
# NON-NESTED PATH: Parse declarations only
|
|
168
|
-
@
|
|
219
|
+
@_pos = decl_start # Reset to start of block
|
|
169
220
|
declarations = parse_declarations
|
|
170
221
|
|
|
171
222
|
# Split comma-separated selectors into individual rules
|
|
@@ -174,7 +225,7 @@ module Cataract
|
|
|
174
225
|
# Determine if we should track this as a selector list
|
|
175
226
|
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
176
227
|
list_id = nil
|
|
177
|
-
if @
|
|
228
|
+
if @_selector_lists_enabled && selectors.size > 1
|
|
178
229
|
list_id = @_next_selector_list_id
|
|
179
230
|
@_next_selector_list_id += 1
|
|
180
231
|
@_selector_lists[list_id] = []
|
|
@@ -182,9 +233,16 @@ module Cataract
|
|
|
182
233
|
|
|
183
234
|
selectors.each do |individual_selector|
|
|
184
235
|
individual_selector.strip!
|
|
236
|
+
|
|
237
|
+
# Check for empty selector in comma-separated list
|
|
238
|
+
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
239
|
+
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
240
|
+
css: @_css, pos: decl_start, type: :invalid_selector_syntax)
|
|
241
|
+
end
|
|
242
|
+
|
|
185
243
|
next if individual_selector.empty?
|
|
186
244
|
|
|
187
|
-
rule_id = @
|
|
245
|
+
rule_id = @_rule_id_counter
|
|
188
246
|
|
|
189
247
|
# Dup declarations for each rule in a selector list to avoid shared state
|
|
190
248
|
# (principle of least surprise - modifying one rule shouldn't affect others)
|
|
@@ -207,7 +265,7 @@ module Cataract
|
|
|
207
265
|
)
|
|
208
266
|
|
|
209
267
|
@rules << rule
|
|
210
|
-
@
|
|
268
|
+
@_rule_id_counter += 1
|
|
211
269
|
|
|
212
270
|
# Track in selector list if applicable
|
|
213
271
|
@_selector_lists[list_id] << rule_id if list_id
|
|
@@ -217,8 +275,10 @@ module Cataract
|
|
|
217
275
|
|
|
218
276
|
{
|
|
219
277
|
rules: @rules,
|
|
220
|
-
_media_index: @
|
|
278
|
+
_media_index: @media_index,
|
|
279
|
+
media_queries: @media_queries,
|
|
221
280
|
_selector_lists: @_selector_lists,
|
|
281
|
+
_media_query_lists: @_media_query_lists,
|
|
222
282
|
imports: @imports,
|
|
223
283
|
charset: @charset,
|
|
224
284
|
_has_nesting: @_has_nesting
|
|
@@ -229,7 +289,7 @@ module Cataract
|
|
|
229
289
|
|
|
230
290
|
# Check if we're at end of input
|
|
231
291
|
def eof?
|
|
232
|
-
@
|
|
292
|
+
@_pos >= @_len
|
|
233
293
|
end
|
|
234
294
|
|
|
235
295
|
# Peek current byte without advancing
|
|
@@ -237,7 +297,7 @@ module Cataract
|
|
|
237
297
|
def peek_byte
|
|
238
298
|
return nil if eof?
|
|
239
299
|
|
|
240
|
-
@
|
|
300
|
+
@_css.getbyte(@_pos)
|
|
241
301
|
end
|
|
242
302
|
|
|
243
303
|
# Delegate to module-level helper methods (now work with bytes)
|
|
@@ -258,19 +318,19 @@ module Cataract
|
|
|
258
318
|
end
|
|
259
319
|
|
|
260
320
|
def skip_whitespace
|
|
261
|
-
@
|
|
321
|
+
@_pos += 1 while !eof? && whitespace?(peek_byte)
|
|
262
322
|
end
|
|
263
323
|
|
|
264
324
|
def skip_comment # rubocop:disable Naming/PredicateMethod
|
|
265
|
-
return false unless peek_byte == BYTE_SLASH && @
|
|
325
|
+
return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
|
|
266
326
|
|
|
267
|
-
@
|
|
268
|
-
while @
|
|
269
|
-
if @
|
|
270
|
-
@
|
|
327
|
+
@_pos += 2 # Skip /*
|
|
328
|
+
while @_pos + 1 < @_len
|
|
329
|
+
if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
|
|
330
|
+
@_pos += 2 # Skip */
|
|
271
331
|
return true
|
|
272
332
|
end
|
|
273
|
-
@
|
|
333
|
+
@_pos += 1
|
|
274
334
|
end
|
|
275
335
|
true
|
|
276
336
|
end
|
|
@@ -283,10 +343,58 @@ module Cataract
|
|
|
283
343
|
# Benchmark shows 15-51% speedup depending on YJIT
|
|
284
344
|
def skip_ws_and_comments
|
|
285
345
|
begin
|
|
286
|
-
old_pos = @
|
|
346
|
+
old_pos = @_pos
|
|
287
347
|
skip_whitespace
|
|
288
348
|
skip_comment
|
|
289
|
-
end until @
|
|
349
|
+
end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Check if a selector contains only valid CSS selector characters and sequences
|
|
353
|
+
# Returns true if valid, false if invalid
|
|
354
|
+
# Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
|
|
355
|
+
def valid_selector_syntax?(selector_text)
|
|
356
|
+
i = 0
|
|
357
|
+
len = selector_text.bytesize
|
|
358
|
+
|
|
359
|
+
while i < len
|
|
360
|
+
byte = selector_text.getbyte(i)
|
|
361
|
+
|
|
362
|
+
# Check for invalid character sequences
|
|
363
|
+
if i + 1 < len
|
|
364
|
+
next_byte = selector_text.getbyte(i + 1)
|
|
365
|
+
# Double dot (..) is invalid
|
|
366
|
+
return false if byte == BYTE_DOT && next_byte == BYTE_DOT
|
|
367
|
+
# Double hash (##) is invalid
|
|
368
|
+
return false if byte == BYTE_HASH && next_byte == BYTE_HASH
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Alphanumeric
|
|
372
|
+
if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
|
|
373
|
+
i += 1
|
|
374
|
+
next
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Whitespace
|
|
378
|
+
if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
379
|
+
i += 1
|
|
380
|
+
next
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Valid CSS selector special characters
|
|
384
|
+
case byte
|
|
385
|
+
when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
|
|
386
|
+
BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
|
|
387
|
+
BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
|
|
388
|
+
BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
|
|
389
|
+
BYTE_COMMA
|
|
390
|
+
i += 1
|
|
391
|
+
else
|
|
392
|
+
# Invalid character found
|
|
393
|
+
return false
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
true
|
|
290
398
|
end
|
|
291
399
|
|
|
292
400
|
# Parse a single CSS declaration (property: value)
|
|
@@ -301,24 +409,24 @@ module Cataract
|
|
|
301
409
|
def parse_single_declaration(pos, end_pos, parse_important)
|
|
302
410
|
# Parse property name (scan until ':')
|
|
303
411
|
prop_start = pos
|
|
304
|
-
while pos < end_pos && @
|
|
305
|
-
@
|
|
412
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
413
|
+
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
306
414
|
pos += 1
|
|
307
415
|
end
|
|
308
416
|
|
|
309
417
|
# Skip if malformed (no colon found)
|
|
310
|
-
if pos >= end_pos || @
|
|
418
|
+
if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
|
|
311
419
|
# Error recovery: skip to next semicolon
|
|
312
|
-
while pos < end_pos && @
|
|
420
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
|
|
313
421
|
pos += 1
|
|
314
422
|
end
|
|
315
|
-
pos += 1 if pos < end_pos && @
|
|
423
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
316
424
|
return [nil, pos]
|
|
317
425
|
end
|
|
318
426
|
|
|
319
427
|
# Trim trailing whitespace from property
|
|
320
428
|
prop_end = pos
|
|
321
|
-
while prop_end > prop_start && whitespace?(@
|
|
429
|
+
while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
|
|
322
430
|
prop_end -= 1
|
|
323
431
|
end
|
|
324
432
|
|
|
@@ -334,19 +442,19 @@ module Cataract
|
|
|
334
442
|
pos += 1 # Skip ':'
|
|
335
443
|
|
|
336
444
|
# Skip leading whitespace in value
|
|
337
|
-
while pos < end_pos && whitespace?(@
|
|
445
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
338
446
|
pos += 1
|
|
339
447
|
end
|
|
340
448
|
|
|
341
449
|
# Parse value (scan until ';' or '}')
|
|
342
450
|
val_start = pos
|
|
343
|
-
while pos < end_pos && @
|
|
451
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
344
452
|
pos += 1
|
|
345
453
|
end
|
|
346
454
|
val_end = pos
|
|
347
455
|
|
|
348
456
|
# Trim trailing whitespace from value
|
|
349
|
-
while val_end > val_start && whitespace?(@
|
|
457
|
+
while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
|
|
350
458
|
val_end -= 1
|
|
351
459
|
end
|
|
352
460
|
|
|
@@ -361,11 +469,14 @@ module Cataract
|
|
|
361
469
|
end
|
|
362
470
|
|
|
363
471
|
# Skip semicolon if present
|
|
364
|
-
pos += 1 if pos < end_pos && @
|
|
472
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
365
473
|
|
|
366
474
|
# Return nil if empty declaration
|
|
367
475
|
return [nil, pos] if prop_end <= prop_start || val_end <= val_start
|
|
368
476
|
|
|
477
|
+
# Convert relative URLs to absolute if enabled
|
|
478
|
+
value = convert_urls_in_value(value)
|
|
479
|
+
|
|
369
480
|
[Declaration.new(property, value, important), pos]
|
|
370
481
|
end
|
|
371
482
|
|
|
@@ -382,8 +493,8 @@ module Cataract
|
|
|
382
493
|
depth = 1
|
|
383
494
|
pos = start_pos
|
|
384
495
|
|
|
385
|
-
while pos < @
|
|
386
|
-
byte = @
|
|
496
|
+
while pos < @_len
|
|
497
|
+
byte = @_css.getbyte(pos)
|
|
387
498
|
if byte == BYTE_RBRACE
|
|
388
499
|
depth -= 1
|
|
389
500
|
return pos if depth == 0
|
|
@@ -393,38 +504,67 @@ module Cataract
|
|
|
393
504
|
pos += 1
|
|
394
505
|
end
|
|
395
506
|
|
|
507
|
+
# Reached EOF without finding matching closing brace
|
|
508
|
+
if @_check_unclosed_blocks && depth > 0
|
|
509
|
+
raise ParseError.new('Unclosed block: missing closing brace',
|
|
510
|
+
css: @_css, pos: start_pos - 1, type: :unclosed_block)
|
|
511
|
+
end
|
|
512
|
+
|
|
396
513
|
pos
|
|
397
514
|
end
|
|
398
515
|
|
|
399
516
|
# Parse selector (read until '{')
|
|
400
517
|
def parse_selector
|
|
401
|
-
start_pos = @
|
|
518
|
+
start_pos = @_pos
|
|
402
519
|
|
|
403
520
|
# Read until we find '{'
|
|
404
521
|
until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
405
|
-
@
|
|
522
|
+
@_pos += 1
|
|
406
523
|
end
|
|
407
524
|
|
|
408
525
|
# If we hit EOF without finding '{', return nil
|
|
409
526
|
return nil if eof?
|
|
410
527
|
|
|
411
528
|
# Extract selector text
|
|
412
|
-
selector_text = byteslice_encoded(start_pos, @
|
|
529
|
+
selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
|
|
413
530
|
|
|
414
531
|
# Skip the '{'
|
|
415
|
-
@
|
|
532
|
+
@_pos += 1 if peek_byte == BYTE_LBRACE
|
|
416
533
|
|
|
417
534
|
# Trim whitespace from selector (in-place to avoid allocation)
|
|
418
535
|
selector_text.strip!
|
|
536
|
+
|
|
537
|
+
# Validate selector (strict mode) - only if enabled to avoid overhead
|
|
538
|
+
if @_check_invalid_selectors
|
|
539
|
+
# Check for empty selector
|
|
540
|
+
if selector_text.empty?
|
|
541
|
+
raise ParseError.new('Invalid selector: empty selector',
|
|
542
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Check if selector starts with a combinator (>, +, ~)
|
|
546
|
+
first_char = selector_text.getbyte(0)
|
|
547
|
+
if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
|
|
548
|
+
raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
|
|
549
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Check selector syntax (whitelist validation for invalid characters/sequences)
|
|
554
|
+
if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
|
|
555
|
+
raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
|
|
556
|
+
css: @_css, pos: start_pos, type: :invalid_selector_syntax)
|
|
557
|
+
end
|
|
558
|
+
|
|
419
559
|
selector_text
|
|
420
560
|
end
|
|
421
561
|
|
|
422
562
|
# Parse mixed block containing declarations AND nested selectors/at-rules
|
|
423
563
|
# Translated from C: see ext/cataract/css_parser.c parse_mixed_block
|
|
424
564
|
# Returns: Array of declarations (only the declarations, not nested rules)
|
|
425
|
-
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym)
|
|
565
|
+
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
|
|
426
566
|
# Check recursion depth to prevent stack overflow
|
|
427
|
-
if @
|
|
567
|
+
if @_depth > MAX_PARSE_DEPTH
|
|
428
568
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
429
569
|
end
|
|
430
570
|
|
|
@@ -433,16 +573,16 @@ module Cataract
|
|
|
433
573
|
|
|
434
574
|
while pos < end_pos
|
|
435
575
|
# Skip whitespace and comments
|
|
436
|
-
while pos < end_pos && whitespace?(@
|
|
576
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
437
577
|
pos += 1
|
|
438
578
|
end
|
|
439
579
|
break if pos >= end_pos
|
|
440
580
|
|
|
441
581
|
# Skip comments
|
|
442
|
-
if pos + 1 < end_pos && @
|
|
582
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
443
583
|
pos += 2
|
|
444
584
|
while pos + 1 < end_pos
|
|
445
|
-
if @
|
|
585
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
446
586
|
pos += 2
|
|
447
587
|
break
|
|
448
588
|
end
|
|
@@ -452,25 +592,25 @@ module Cataract
|
|
|
452
592
|
end
|
|
453
593
|
|
|
454
594
|
# Check if this is a nested @media query
|
|
455
|
-
if @
|
|
595
|
+
if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
|
|
456
596
|
byteslice_encoded(pos, 6) == '@media' &&
|
|
457
|
-
(pos + 6 >= end_pos || whitespace?(@
|
|
597
|
+
(pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
|
|
458
598
|
# Nested @media - parse with parent selector as context
|
|
459
599
|
media_start = pos + 6
|
|
460
|
-
while media_start < end_pos && whitespace?(@
|
|
600
|
+
while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
|
|
461
601
|
media_start += 1
|
|
462
602
|
end
|
|
463
603
|
|
|
464
604
|
# Find opening brace
|
|
465
605
|
media_query_end = media_start
|
|
466
|
-
while media_query_end < end_pos && @
|
|
606
|
+
while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
|
|
467
607
|
media_query_end += 1
|
|
468
608
|
end
|
|
469
609
|
break if media_query_end >= end_pos
|
|
470
610
|
|
|
471
611
|
# Extract media query (trim trailing whitespace)
|
|
472
612
|
media_query_end_trimmed = media_query_end
|
|
473
|
-
while media_query_end_trimmed > media_start && whitespace?(@
|
|
613
|
+
while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
|
|
474
614
|
media_query_end_trimmed -= 1
|
|
475
615
|
end
|
|
476
616
|
media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
|
|
@@ -489,15 +629,48 @@ module Cataract
|
|
|
489
629
|
# Combine media queries: parent + child
|
|
490
630
|
combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
|
|
491
631
|
|
|
632
|
+
# Create MediaQuery object for this nested @media
|
|
633
|
+
# If we're already in a media query context, combine with parent
|
|
634
|
+
nested_media_query_id = if parent_media_query_id
|
|
635
|
+
# Combine with parent MediaQuery
|
|
636
|
+
parent_mq = @media_queries[parent_media_query_id]
|
|
637
|
+
|
|
638
|
+
# This should never happen - parent_media_query_id should always be valid
|
|
639
|
+
if parent_mq.nil?
|
|
640
|
+
raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Combine parent media query with child
|
|
644
|
+
_child_type, child_conditions = parse_media_query_parts(media_query_str)
|
|
645
|
+
combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
|
|
646
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
647
|
+
@media_queries << combined_mq
|
|
648
|
+
combined_id = @_media_query_id_counter
|
|
649
|
+
@_media_query_id_counter += 1
|
|
650
|
+
combined_id
|
|
651
|
+
else
|
|
652
|
+
# No parent context, just use the child media query
|
|
653
|
+
media_type, media_conditions = parse_media_query_parts(media_query_str)
|
|
654
|
+
nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
655
|
+
@media_queries << nested_media_query
|
|
656
|
+
mq_id = @_media_query_id_counter
|
|
657
|
+
@_media_query_id_counter += 1
|
|
658
|
+
mq_id
|
|
659
|
+
end
|
|
660
|
+
|
|
492
661
|
# Create rule ID for this media rule
|
|
493
|
-
media_rule_id = @
|
|
494
|
-
@
|
|
662
|
+
media_rule_id = @_rule_id_counter
|
|
663
|
+
@_rule_id_counter += 1
|
|
664
|
+
|
|
665
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
666
|
+
rule_position = @rules.length
|
|
667
|
+
@rules << nil # Placeholder
|
|
495
668
|
|
|
496
|
-
# Parse mixed block recursively
|
|
497
|
-
@
|
|
669
|
+
# Parse mixed block recursively with the nested media query ID as context
|
|
670
|
+
@_depth += 1
|
|
498
671
|
media_declarations = parse_mixed_block(media_block_start, media_block_end,
|
|
499
|
-
parent_selector, media_rule_id, combined_media_sym)
|
|
500
|
-
@
|
|
672
|
+
parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
|
|
673
|
+
@_depth -= 1
|
|
501
674
|
|
|
502
675
|
# Create rule with parent selector and declarations, associated with combined media query
|
|
503
676
|
rule = Rule.new(
|
|
@@ -506,34 +679,34 @@ module Cataract
|
|
|
506
679
|
media_declarations,
|
|
507
680
|
nil, # specificity
|
|
508
681
|
parent_rule_id,
|
|
509
|
-
nil
|
|
682
|
+
nil, # nesting_style (nil for @media nesting)
|
|
683
|
+
nil, # selector_list_id
|
|
684
|
+
nested_media_query_id # media_query_id
|
|
510
685
|
)
|
|
511
686
|
|
|
512
687
|
# Mark that we have nesting
|
|
513
688
|
@_has_nesting = true unless parent_rule_id.nil?
|
|
514
689
|
|
|
515
|
-
|
|
516
|
-
@
|
|
517
|
-
@_media_index[combined_media_sym] << media_rule_id
|
|
518
|
-
|
|
690
|
+
# Replace placeholder with actual rule
|
|
691
|
+
@rules[rule_position] = rule
|
|
519
692
|
next
|
|
520
693
|
end
|
|
521
694
|
|
|
522
695
|
# Check if this is a nested selector
|
|
523
|
-
byte = @
|
|
696
|
+
byte = @_css.getbyte(pos)
|
|
524
697
|
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
525
698
|
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
526
699
|
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
|
|
527
700
|
# Find the opening brace
|
|
528
701
|
nested_sel_start = pos
|
|
529
|
-
while pos < end_pos && @
|
|
702
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
|
|
530
703
|
pos += 1
|
|
531
704
|
end
|
|
532
705
|
break if pos >= end_pos
|
|
533
706
|
|
|
534
707
|
nested_sel_end = pos
|
|
535
708
|
# Trim trailing whitespace
|
|
536
|
-
while nested_sel_end > nested_sel_start && whitespace?(@
|
|
709
|
+
while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
|
|
537
710
|
nested_sel_end -= 1
|
|
538
711
|
end
|
|
539
712
|
|
|
@@ -557,14 +730,18 @@ module Cataract
|
|
|
557
730
|
resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
|
|
558
731
|
|
|
559
732
|
# Get rule ID
|
|
560
|
-
rule_id = @
|
|
561
|
-
@
|
|
733
|
+
rule_id = @_rule_id_counter
|
|
734
|
+
@_rule_id_counter += 1
|
|
735
|
+
|
|
736
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
737
|
+
rule_position = @rules.length
|
|
738
|
+
@rules << nil # Placeholder
|
|
562
739
|
|
|
563
740
|
# Recursively parse nested block
|
|
564
|
-
@
|
|
741
|
+
@_depth += 1
|
|
565
742
|
nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
|
|
566
|
-
resolved_selector, rule_id, parent_media_sym)
|
|
567
|
-
@
|
|
743
|
+
resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
|
|
744
|
+
@_depth -= 1
|
|
568
745
|
|
|
569
746
|
# Create rule for nested selector
|
|
570
747
|
rule = Rule.new(
|
|
@@ -579,9 +756,8 @@ module Cataract
|
|
|
579
756
|
# Mark that we have nesting
|
|
580
757
|
@_has_nesting = true unless parent_rule_id.nil?
|
|
581
758
|
|
|
582
|
-
|
|
583
|
-
@
|
|
584
|
-
@_media_index[parent_media_sym] << rule_id if parent_media_sym
|
|
759
|
+
# Replace placeholder with actual rule
|
|
760
|
+
@rules[rule_position] = rule
|
|
585
761
|
end
|
|
586
762
|
|
|
587
763
|
next
|
|
@@ -607,28 +783,40 @@ module Cataract
|
|
|
607
783
|
|
|
608
784
|
# Check for closing brace
|
|
609
785
|
if peek_byte == BYTE_RBRACE
|
|
610
|
-
@
|
|
786
|
+
@_pos += 1 # consume '}'
|
|
611
787
|
break
|
|
612
788
|
end
|
|
613
789
|
|
|
614
790
|
# Parse property name (read until ':')
|
|
615
|
-
property_start = @
|
|
791
|
+
property_start = @_pos
|
|
616
792
|
until eof?
|
|
617
793
|
byte = peek_byte
|
|
618
794
|
break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
619
795
|
|
|
620
|
-
@
|
|
796
|
+
@_pos += 1
|
|
621
797
|
end
|
|
622
798
|
|
|
623
799
|
# Skip if no colon found (malformed)
|
|
624
800
|
if eof? || peek_byte != BYTE_COLON
|
|
801
|
+
# Check for malformed declaration (strict mode)
|
|
802
|
+
if @_check_malformed_declarations
|
|
803
|
+
property_text = byteslice_encoded(property_start, @_pos - property_start).strip
|
|
804
|
+
if property_text.empty?
|
|
805
|
+
raise ParseError.new('Malformed declaration: missing property name',
|
|
806
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
807
|
+
else
|
|
808
|
+
raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
|
|
809
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
625
813
|
# Try to recover by finding next ; or }
|
|
626
814
|
skip_to_semicolon_or_brace
|
|
627
815
|
next
|
|
628
816
|
end
|
|
629
817
|
|
|
630
818
|
# Extract property name - use UTF-8 encoding to support custom properties with Unicode
|
|
631
|
-
property = byteslice_encoded(property_start, @
|
|
819
|
+
property = byteslice_encoded(property_start, @_pos - property_start)
|
|
632
820
|
property.strip!
|
|
633
821
|
# Custom properties (--foo) are case-sensitive and can contain Unicode
|
|
634
822
|
# Regular properties are ASCII-only and case-insensitive
|
|
@@ -637,12 +825,12 @@ module Cataract
|
|
|
637
825
|
property.force_encoding('US-ASCII')
|
|
638
826
|
property.downcase!
|
|
639
827
|
end
|
|
640
|
-
@
|
|
828
|
+
@_pos += 1 # skip ':'
|
|
641
829
|
|
|
642
830
|
skip_ws_and_comments
|
|
643
831
|
|
|
644
832
|
# Parse value (read until ';' or '}', but respect quoted strings)
|
|
645
|
-
value_start = @
|
|
833
|
+
value_start = @_pos
|
|
646
834
|
important = false
|
|
647
835
|
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
648
836
|
|
|
@@ -653,9 +841,9 @@ module Cataract
|
|
|
653
841
|
# Inside quoted string - only exit on matching quote
|
|
654
842
|
if byte == in_quote
|
|
655
843
|
in_quote = nil
|
|
656
|
-
elsif byte == BYTE_BACKSLASH && @
|
|
844
|
+
elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
657
845
|
# Skip escaped character
|
|
658
|
-
@
|
|
846
|
+
@_pos += 1
|
|
659
847
|
end
|
|
660
848
|
else
|
|
661
849
|
# Not in quote - check for terminators or quote start
|
|
@@ -666,14 +854,14 @@ module Cataract
|
|
|
666
854
|
end
|
|
667
855
|
end
|
|
668
856
|
|
|
669
|
-
@
|
|
857
|
+
@_pos += 1
|
|
670
858
|
end
|
|
671
859
|
|
|
672
|
-
value = byteslice_encoded(value_start, @
|
|
860
|
+
value = byteslice_encoded(value_start, @_pos - value_start)
|
|
673
861
|
value.strip!
|
|
674
862
|
|
|
675
863
|
# Check for !important (byte-by-byte, no regexp)
|
|
676
|
-
if value.bytesize
|
|
864
|
+
if value.bytesize >= 10
|
|
677
865
|
# Scan backwards to find !important
|
|
678
866
|
i = value.bytesize - 1
|
|
679
867
|
# Skip trailing whitespace
|
|
@@ -704,8 +892,17 @@ module Cataract
|
|
|
704
892
|
end
|
|
705
893
|
end
|
|
706
894
|
|
|
895
|
+
# Check for empty value (strict mode) - only if enabled to avoid overhead
|
|
896
|
+
if @_check_empty_values && value.empty?
|
|
897
|
+
raise ParseError.new("Empty value for property '#{property}'",
|
|
898
|
+
css: @_css, pos: property_start, type: :empty_value)
|
|
899
|
+
end
|
|
900
|
+
|
|
707
901
|
# Skip semicolon if present
|
|
708
|
-
@
|
|
902
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
903
|
+
|
|
904
|
+
# Convert relative URLs to absolute if enabled
|
|
905
|
+
value = convert_urls_in_value(value)
|
|
709
906
|
|
|
710
907
|
# Create Declaration struct
|
|
711
908
|
declarations << Declaration.new(property, value, important)
|
|
@@ -717,35 +914,35 @@ module Cataract
|
|
|
717
914
|
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
718
915
|
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
719
916
|
def parse_at_rule
|
|
720
|
-
at_rule_start = @
|
|
721
|
-
@
|
|
917
|
+
at_rule_start = @_pos # Points to '@'
|
|
918
|
+
@_pos += 1 # skip '@'
|
|
722
919
|
|
|
723
920
|
# Find end of at-rule name (stop at whitespace or opening brace)
|
|
724
|
-
name_start = @
|
|
921
|
+
name_start = @_pos
|
|
725
922
|
until eof?
|
|
726
923
|
byte = peek_byte
|
|
727
924
|
break if whitespace?(byte) || byte == BYTE_LBRACE
|
|
728
925
|
|
|
729
|
-
@
|
|
926
|
+
@_pos += 1
|
|
730
927
|
end
|
|
731
928
|
|
|
732
|
-
at_rule_name = byteslice_encoded(name_start, @
|
|
929
|
+
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
733
930
|
|
|
734
931
|
# Handle @charset specially - it's just @charset "value";
|
|
735
932
|
if at_rule_name == 'charset'
|
|
736
933
|
skip_ws_and_comments
|
|
737
934
|
# Read until semicolon
|
|
738
|
-
value_start = @
|
|
935
|
+
value_start = @_pos
|
|
739
936
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
740
|
-
@
|
|
937
|
+
@_pos += 1
|
|
741
938
|
end
|
|
742
939
|
|
|
743
|
-
charset_value = byteslice_encoded(value_start, @
|
|
940
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
744
941
|
charset_value.strip!
|
|
745
942
|
# Remove quotes
|
|
746
943
|
@charset = charset_value.delete('"\'')
|
|
747
944
|
|
|
748
|
-
@
|
|
945
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
749
946
|
return
|
|
750
947
|
end
|
|
751
948
|
|
|
@@ -756,9 +953,9 @@ module Cataract
|
|
|
756
953
|
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
757
954
|
# Skip to semicolon
|
|
758
955
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
759
|
-
@
|
|
956
|
+
@_pos += 1
|
|
760
957
|
end
|
|
761
|
-
@
|
|
958
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
762
959
|
return
|
|
763
960
|
end
|
|
764
961
|
|
|
@@ -771,30 +968,44 @@ module Cataract
|
|
|
771
968
|
if AT_RULE_TYPES.include?(at_rule_name)
|
|
772
969
|
skip_ws_and_comments
|
|
773
970
|
|
|
971
|
+
# Remember start of condition for error reporting
|
|
972
|
+
condition_start = @_pos
|
|
973
|
+
|
|
774
974
|
# Skip to opening brace
|
|
975
|
+
condition_end = @_pos
|
|
775
976
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
776
|
-
|
|
977
|
+
condition_end = @_pos
|
|
978
|
+
@_pos += 1
|
|
777
979
|
end
|
|
778
980
|
|
|
779
981
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
780
982
|
|
|
781
|
-
@
|
|
983
|
+
# Validate condition (strict mode) - @supports, @container, @scope require conditions
|
|
984
|
+
if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
|
|
985
|
+
condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
|
|
986
|
+
if condition_str.empty?
|
|
987
|
+
raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
|
|
988
|
+
css: @_css, pos: condition_start, type: :malformed_at_rule)
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
@_pos += 1 # skip '{'
|
|
782
993
|
|
|
783
994
|
# Find matching closing brace
|
|
784
|
-
block_start = @
|
|
785
|
-
block_end = find_matching_brace(@
|
|
995
|
+
block_start = @_pos
|
|
996
|
+
block_end = find_matching_brace(@_pos)
|
|
786
997
|
|
|
787
998
|
# Check depth before recursing
|
|
788
|
-
if @
|
|
999
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
789
1000
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
790
1001
|
end
|
|
791
1002
|
|
|
792
1003
|
# Recursively parse block content (preserve parent media context)
|
|
793
1004
|
nested_parser = Parser.new(
|
|
794
1005
|
byteslice_encoded(block_start, block_end - block_start),
|
|
795
|
-
parser_options: @
|
|
796
|
-
parent_media_sym: @
|
|
797
|
-
depth: @
|
|
1006
|
+
parser_options: @_parser_options,
|
|
1007
|
+
parent_media_sym: @_parent_media_sym,
|
|
1008
|
+
depth: @_depth + 1
|
|
798
1009
|
)
|
|
799
1010
|
|
|
800
1011
|
nested_result = nested_parser.parse
|
|
@@ -804,33 +1015,29 @@ module Cataract
|
|
|
804
1015
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
805
1016
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
806
1017
|
new_list_id = list_id + list_id_offset
|
|
807
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
1018
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
808
1019
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
809
1020
|
end
|
|
810
1021
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
811
1022
|
end
|
|
812
1023
|
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
@_media_index[media] ||= []
|
|
816
|
-
# Use each + << instead of concat + map (1.20x faster for small arrays)
|
|
817
|
-
rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
|
|
818
|
-
end
|
|
1024
|
+
# NOTE: We no longer build media_index during parse
|
|
1025
|
+
# It will be built from MediaQuery objects after import resolution
|
|
819
1026
|
|
|
820
1027
|
# Add nested rules to main rules array
|
|
821
1028
|
nested_result[:rules].each do |rule|
|
|
822
|
-
rule.id = @
|
|
1029
|
+
rule.id = @_rule_id_counter
|
|
823
1030
|
# Update selector_list_id if applicable
|
|
824
1031
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
825
1032
|
rule.selector_list_id += list_id_offset
|
|
826
1033
|
end
|
|
827
|
-
@
|
|
1034
|
+
@_rule_id_counter += 1
|
|
828
1035
|
@rules << rule
|
|
829
1036
|
end
|
|
830
1037
|
|
|
831
1038
|
# Move position past the closing brace
|
|
832
|
-
@
|
|
833
|
-
@
|
|
1039
|
+
@_pos = block_end
|
|
1040
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
834
1041
|
|
|
835
1042
|
return
|
|
836
1043
|
end
|
|
@@ -840,52 +1047,90 @@ module Cataract
|
|
|
840
1047
|
skip_ws_and_comments
|
|
841
1048
|
|
|
842
1049
|
# Find media query (up to opening brace)
|
|
843
|
-
mq_start = @
|
|
1050
|
+
mq_start = @_pos
|
|
844
1051
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
845
|
-
@
|
|
1052
|
+
@_pos += 1
|
|
846
1053
|
end
|
|
847
1054
|
|
|
848
1055
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
849
1056
|
|
|
850
|
-
mq_end = @
|
|
1057
|
+
mq_end = @_pos
|
|
851
1058
|
# Trim trailing whitespace
|
|
852
|
-
while mq_end > mq_start && whitespace?(@
|
|
1059
|
+
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
853
1060
|
mq_end -= 1
|
|
854
1061
|
end
|
|
855
1062
|
|
|
856
1063
|
child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
|
|
857
1064
|
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
858
1065
|
child_media_string.strip!
|
|
1066
|
+
|
|
1067
|
+
# Validate @media has a query (strict mode)
|
|
1068
|
+
if @_check_malformed_at_rules && child_media_string.empty?
|
|
1069
|
+
raise ParseError.new('Malformed @media: missing media query or condition',
|
|
1070
|
+
css: @_css, pos: mq_start, type: :malformed_at_rule)
|
|
1071
|
+
end
|
|
1072
|
+
|
|
859
1073
|
child_media_sym = child_media_string.to_sym
|
|
860
1074
|
|
|
1075
|
+
# Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
|
|
1076
|
+
# Per W3C spec, comma acts as logical OR - each query is independent
|
|
1077
|
+
media_query_strings = child_media_string.split(',').map(&:strip)
|
|
1078
|
+
|
|
1079
|
+
# Create MediaQuery objects for each query in the list
|
|
1080
|
+
media_query_ids = []
|
|
1081
|
+
media_query_strings.each do |query_string|
|
|
1082
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1083
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1084
|
+
@media_queries << media_query
|
|
1085
|
+
media_query_ids << @_media_query_id_counter
|
|
1086
|
+
@_media_query_id_counter += 1
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# If multiple queries, track them as a list for serialization
|
|
1090
|
+
if media_query_ids.size > 1
|
|
1091
|
+
@_media_query_lists[@_next_media_query_list_id] = media_query_ids
|
|
1092
|
+
@_next_media_query_list_id += 1
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# Use first query ID as the primary one for rules in this block
|
|
1096
|
+
current_media_query_id = media_query_ids.first
|
|
1097
|
+
|
|
861
1098
|
# Combine with parent media context
|
|
862
|
-
combined_media_sym = combine_media_queries(@
|
|
1099
|
+
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1100
|
+
|
|
1101
|
+
# NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
|
|
1102
|
+
# create separate parsers without passing parent_media_query_id (see nested_parser creation below).
|
|
1103
|
+
# MediaQuery combining for nested @media happens in parse_mixed_block instead.
|
|
1104
|
+
# So this is just an alias to current_media_query_id.
|
|
1105
|
+
combined_media_query_id = current_media_query_id
|
|
863
1106
|
|
|
864
1107
|
# Check media query limit
|
|
865
|
-
unless @
|
|
866
|
-
@
|
|
867
|
-
if @
|
|
1108
|
+
unless @media_index.key?(combined_media_sym)
|
|
1109
|
+
@_media_query_count += 1
|
|
1110
|
+
if @_media_query_count > MAX_MEDIA_QUERIES
|
|
868
1111
|
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
869
1112
|
end
|
|
870
1113
|
end
|
|
871
1114
|
|
|
872
|
-
@
|
|
1115
|
+
@_pos += 1 # skip '{'
|
|
873
1116
|
|
|
874
1117
|
# Find matching closing brace
|
|
875
|
-
block_start = @
|
|
876
|
-
block_end = find_matching_brace(@
|
|
1118
|
+
block_start = @_pos
|
|
1119
|
+
block_end = find_matching_brace(@_pos)
|
|
877
1120
|
|
|
878
1121
|
# Check depth before recursing
|
|
879
|
-
if @
|
|
1122
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
880
1123
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
881
1124
|
end
|
|
882
1125
|
|
|
883
1126
|
# Parse the content with the combined media context
|
|
1127
|
+
# Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
|
|
1128
|
+
# The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
|
|
884
1129
|
nested_parser = Parser.new(
|
|
885
1130
|
byteslice_encoded(block_start, block_end - block_start),
|
|
886
|
-
parser_options: @
|
|
1131
|
+
parser_options: @_parser_options,
|
|
887
1132
|
parent_media_sym: combined_media_sym,
|
|
888
|
-
depth: @
|
|
1133
|
+
depth: @_depth + 1
|
|
889
1134
|
)
|
|
890
1135
|
|
|
891
1136
|
nested_result = nested_parser.parse
|
|
@@ -895,50 +1140,84 @@ module Cataract
|
|
|
895
1140
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
896
1141
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
897
1142
|
new_list_id = list_id + list_id_offset
|
|
898
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
1143
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
899
1144
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
900
1145
|
end
|
|
901
1146
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
902
1147
|
end
|
|
903
1148
|
|
|
904
|
-
# Merge nested
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1149
|
+
# Merge nested MediaQuery objects with offsetted IDs
|
|
1150
|
+
mq_id_offset = @_media_query_id_counter
|
|
1151
|
+
if nested_result[:media_queries] && !nested_result[:media_queries].empty?
|
|
1152
|
+
nested_result[:media_queries].each do |mq|
|
|
1153
|
+
# Create new MediaQuery with offsetted ID
|
|
1154
|
+
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
1155
|
+
@media_queries << new_mq
|
|
1156
|
+
end
|
|
1157
|
+
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
# Merge nested media_query_lists with offsetted IDs
|
|
1161
|
+
if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
|
|
1162
|
+
nested_result[:_media_query_lists].each do |list_id, mq_ids|
|
|
1163
|
+
# Offset the list_id and media_query_ids
|
|
1164
|
+
new_list_id = list_id + @_next_media_query_list_id
|
|
1165
|
+
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1166
|
+
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1167
|
+
end
|
|
1168
|
+
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
909
1169
|
end
|
|
910
1170
|
|
|
911
|
-
#
|
|
1171
|
+
# Merge nested media_index into ours (for nested @media)
|
|
1172
|
+
# Note: We no longer build media_index during parse
|
|
1173
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1174
|
+
|
|
1175
|
+
# Add nested rules to main rules array
|
|
912
1176
|
nested_result[:rules].each do |rule|
|
|
913
|
-
rule.id = @
|
|
1177
|
+
rule.id = @_rule_id_counter
|
|
914
1178
|
# Update selector_list_id if applicable
|
|
915
1179
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
916
1180
|
rule.selector_list_id += list_id_offset
|
|
917
1181
|
end
|
|
918
1182
|
|
|
919
|
-
#
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
@
|
|
1183
|
+
# Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
|
|
1184
|
+
if rule.media_query_id
|
|
1185
|
+
# Nested parser assigned a media_query_id - need to combine with our context
|
|
1186
|
+
nested_mq_id = rule.media_query_id + mq_id_offset
|
|
1187
|
+
nested_mq = @media_queries[nested_mq_id]
|
|
1188
|
+
|
|
1189
|
+
# Combine nested media query with our media context
|
|
1190
|
+
if nested_mq && combined_media_query_id
|
|
1191
|
+
outer_mq = @media_queries[combined_media_query_id]
|
|
1192
|
+
if outer_mq
|
|
1193
|
+
# Combine media queries directly without string building
|
|
1194
|
+
combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
|
|
1195
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
1196
|
+
@media_queries << combined_mq
|
|
1197
|
+
rule.media_query_id = @_media_query_id_counter
|
|
1198
|
+
@_media_query_id_counter += 1
|
|
1199
|
+
else
|
|
1200
|
+
rule.media_query_id = nested_mq_id
|
|
1201
|
+
end
|
|
1202
|
+
else
|
|
1203
|
+
rule.media_query_id = nested_mq_id
|
|
928
1204
|
end
|
|
1205
|
+
elsif rule.respond_to?(:media_query_id=)
|
|
1206
|
+
# Assign the combined media_query_id if no media_query_id set
|
|
1207
|
+
# (applies to both Rule and AtRule)
|
|
1208
|
+
rule.media_query_id = combined_media_query_id
|
|
929
1209
|
end
|
|
930
1210
|
|
|
931
|
-
#
|
|
932
|
-
|
|
933
|
-
@_media_index[combined_media_sym] << @rule_id_counter
|
|
1211
|
+
# NOTE: We no longer build media_index during parse
|
|
1212
|
+
# It will be built from MediaQuery objects after import resolution
|
|
934
1213
|
|
|
935
|
-
@
|
|
1214
|
+
@_rule_id_counter += 1
|
|
936
1215
|
@rules << rule
|
|
937
1216
|
end
|
|
938
1217
|
|
|
939
1218
|
# Move position past the closing brace
|
|
940
|
-
@
|
|
941
|
-
@
|
|
1219
|
+
@_pos = block_end
|
|
1220
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
942
1221
|
|
|
943
1222
|
return
|
|
944
1223
|
end
|
|
@@ -954,26 +1233,26 @@ module Cataract
|
|
|
954
1233
|
|
|
955
1234
|
# Skip to opening brace
|
|
956
1235
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
957
|
-
@
|
|
1236
|
+
@_pos += 1
|
|
958
1237
|
end
|
|
959
1238
|
|
|
960
1239
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
961
1240
|
|
|
962
|
-
selector_end = @
|
|
1241
|
+
selector_end = @_pos
|
|
963
1242
|
# Trim trailing whitespace
|
|
964
|
-
while selector_end > selector_start && whitespace?(@
|
|
1243
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
965
1244
|
selector_end -= 1
|
|
966
1245
|
end
|
|
967
1246
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
968
1247
|
|
|
969
|
-
@
|
|
1248
|
+
@_pos += 1 # skip '{'
|
|
970
1249
|
|
|
971
1250
|
# Find matching closing brace
|
|
972
|
-
block_start = @
|
|
973
|
-
block_end = find_matching_brace(@
|
|
1251
|
+
block_start = @_pos
|
|
1252
|
+
block_end = find_matching_brace(@_pos)
|
|
974
1253
|
|
|
975
1254
|
# Check depth before recursing
|
|
976
|
-
if @
|
|
1255
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
977
1256
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
978
1257
|
end
|
|
979
1258
|
|
|
@@ -981,23 +1260,23 @@ module Cataract
|
|
|
981
1260
|
# Create a nested parser context
|
|
982
1261
|
nested_parser = Parser.new(
|
|
983
1262
|
byteslice_encoded(block_start, block_end - block_start),
|
|
984
|
-
parser_options: @
|
|
985
|
-
depth: @
|
|
1263
|
+
parser_options: @_parser_options,
|
|
1264
|
+
depth: @_depth + 1
|
|
986
1265
|
)
|
|
987
1266
|
nested_result = nested_parser.parse
|
|
988
1267
|
content = nested_result[:rules]
|
|
989
1268
|
|
|
990
1269
|
# Move position past the closing brace
|
|
991
|
-
@
|
|
1270
|
+
@_pos = block_end
|
|
992
1271
|
# The closing brace should be at block_end
|
|
993
|
-
@
|
|
1272
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
994
1273
|
|
|
995
1274
|
# Get rule ID and increment
|
|
996
|
-
rule_id = @
|
|
997
|
-
@
|
|
1275
|
+
rule_id = @_rule_id_counter
|
|
1276
|
+
@_rule_id_counter += 1
|
|
998
1277
|
|
|
999
1278
|
# Create AtRule with nested rules
|
|
1000
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1279
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1001
1280
|
@rules << at_rule
|
|
1002
1281
|
|
|
1003
1282
|
return
|
|
@@ -1010,38 +1289,38 @@ module Cataract
|
|
|
1010
1289
|
|
|
1011
1290
|
# Skip to opening brace
|
|
1012
1291
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
1013
|
-
@
|
|
1292
|
+
@_pos += 1
|
|
1014
1293
|
end
|
|
1015
1294
|
|
|
1016
1295
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1017
1296
|
|
|
1018
|
-
selector_end = @
|
|
1297
|
+
selector_end = @_pos
|
|
1019
1298
|
# Trim trailing whitespace
|
|
1020
|
-
while selector_end > selector_start && whitespace?(@
|
|
1299
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1021
1300
|
selector_end -= 1
|
|
1022
1301
|
end
|
|
1023
1302
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1024
1303
|
|
|
1025
|
-
@
|
|
1304
|
+
@_pos += 1 # skip '{'
|
|
1026
1305
|
|
|
1027
1306
|
# Find matching closing brace
|
|
1028
|
-
decl_start = @
|
|
1029
|
-
decl_end = find_matching_brace(@
|
|
1307
|
+
decl_start = @_pos
|
|
1308
|
+
decl_end = find_matching_brace(@_pos)
|
|
1030
1309
|
|
|
1031
1310
|
# Parse declarations
|
|
1032
1311
|
content = parse_declarations_block(decl_start, decl_end)
|
|
1033
1312
|
|
|
1034
1313
|
# Move position past the closing brace
|
|
1035
|
-
@
|
|
1314
|
+
@_pos = decl_end
|
|
1036
1315
|
# The closing brace should be at decl_end
|
|
1037
|
-
@
|
|
1316
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1038
1317
|
|
|
1039
1318
|
# Get rule ID and increment
|
|
1040
|
-
rule_id = @
|
|
1041
|
-
@
|
|
1319
|
+
rule_id = @_rule_id_counter
|
|
1320
|
+
@_rule_id_counter += 1
|
|
1042
1321
|
|
|
1043
1322
|
# Create AtRule with declarations
|
|
1044
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1323
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1045
1324
|
@rules << at_rule
|
|
1046
1325
|
|
|
1047
1326
|
return
|
|
@@ -1053,26 +1332,26 @@ module Cataract
|
|
|
1053
1332
|
|
|
1054
1333
|
# Skip to opening brace
|
|
1055
1334
|
until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
1056
|
-
@
|
|
1335
|
+
@_pos += 1
|
|
1057
1336
|
end
|
|
1058
1337
|
|
|
1059
1338
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1060
1339
|
|
|
1061
|
-
selector_end = @
|
|
1340
|
+
selector_end = @_pos
|
|
1062
1341
|
# Trim trailing whitespace
|
|
1063
|
-
while selector_end > selector_start && whitespace?(@
|
|
1342
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1064
1343
|
selector_end -= 1
|
|
1065
1344
|
end
|
|
1066
1345
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1067
1346
|
|
|
1068
|
-
@
|
|
1347
|
+
@_pos += 1 # skip '{'
|
|
1069
1348
|
|
|
1070
1349
|
# Parse declarations
|
|
1071
1350
|
declarations = parse_declarations
|
|
1072
1351
|
|
|
1073
1352
|
# Create Rule with declarations
|
|
1074
1353
|
rule = Rule.new(
|
|
1075
|
-
@
|
|
1354
|
+
@_rule_id_counter, # id
|
|
1076
1355
|
selector, # selector (e.g., "@property --main-color")
|
|
1077
1356
|
declarations, # declarations
|
|
1078
1357
|
nil, # specificity
|
|
@@ -1081,7 +1360,7 @@ module Cataract
|
|
|
1081
1360
|
)
|
|
1082
1361
|
|
|
1083
1362
|
@rules << rule
|
|
1084
|
-
@
|
|
1363
|
+
@_rule_id_counter += 1
|
|
1085
1364
|
end
|
|
1086
1365
|
|
|
1087
1366
|
# Check if block contains nested selectors vs just declarations
|
|
@@ -1091,16 +1370,16 @@ module Cataract
|
|
|
1091
1370
|
|
|
1092
1371
|
while pos < end_pos
|
|
1093
1372
|
# Skip whitespace
|
|
1094
|
-
while pos < end_pos && whitespace?(@
|
|
1373
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1095
1374
|
pos += 1
|
|
1096
1375
|
end
|
|
1097
1376
|
break if pos >= end_pos
|
|
1098
1377
|
|
|
1099
1378
|
# Skip comments
|
|
1100
|
-
if pos + 1 < end_pos && @
|
|
1379
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
1101
1380
|
pos += 2
|
|
1102
1381
|
while pos + 1 < end_pos
|
|
1103
|
-
if @
|
|
1382
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
1104
1383
|
pos += 2
|
|
1105
1384
|
break
|
|
1106
1385
|
end
|
|
@@ -1110,24 +1389,24 @@ module Cataract
|
|
|
1110
1389
|
end
|
|
1111
1390
|
|
|
1112
1391
|
# Check for nested selector indicators
|
|
1113
|
-
byte = @
|
|
1392
|
+
byte = @_css.getbyte(pos)
|
|
1114
1393
|
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
1115
1394
|
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
1116
1395
|
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
|
|
1117
1396
|
# Look ahead - if followed by {, it's likely a nested selector
|
|
1118
1397
|
lookahead = pos + 1
|
|
1119
|
-
while lookahead < end_pos && @
|
|
1120
|
-
@
|
|
1398
|
+
while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
|
|
1399
|
+
@_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
|
|
1121
1400
|
lookahead += 1
|
|
1122
1401
|
end
|
|
1123
|
-
return true if lookahead < end_pos && @
|
|
1402
|
+
return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
|
|
1124
1403
|
end
|
|
1125
1404
|
|
|
1126
1405
|
# Check for @media, @supports, etc nested inside
|
|
1127
1406
|
return true if byte == BYTE_AT
|
|
1128
1407
|
|
|
1129
1408
|
# Skip to next line or semicolon
|
|
1130
|
-
while pos < end_pos && @
|
|
1409
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
|
|
1131
1410
|
pos += 1
|
|
1132
1411
|
end
|
|
1133
1412
|
pos += 1 if pos < end_pos
|
|
@@ -1263,10 +1542,10 @@ module Cataract
|
|
|
1263
1542
|
# Skip to next semicolon or closing brace (error recovery)
|
|
1264
1543
|
def skip_to_semicolon_or_brace
|
|
1265
1544
|
until eof? || peek_byte == BYTE_SEMICOLON || peek_byte == BYTE_RBRACE # Flip to save a not_opt instruction: while !eof? && peek_byte != BYTE_SEMICOLON && peek_byte != BYTE_RBRACE
|
|
1266
|
-
@
|
|
1545
|
+
@_pos += 1
|
|
1267
1546
|
end
|
|
1268
1547
|
|
|
1269
|
-
@
|
|
1548
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1270
1549
|
end
|
|
1271
1550
|
|
|
1272
1551
|
# Parse an @import statement
|
|
@@ -1277,9 +1556,9 @@ module Cataract
|
|
|
1277
1556
|
|
|
1278
1557
|
# Check for optional url(
|
|
1279
1558
|
has_url_function = false
|
|
1280
|
-
if @
|
|
1559
|
+
if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
|
|
1281
1560
|
has_url_function = true
|
|
1282
|
-
@
|
|
1561
|
+
@_pos += 4
|
|
1283
1562
|
skip_ws_and_comments
|
|
1284
1563
|
end
|
|
1285
1564
|
|
|
@@ -1288,24 +1567,24 @@ module Cataract
|
|
|
1288
1567
|
if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
|
|
1289
1568
|
# Invalid @import, skip to semicolon
|
|
1290
1569
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1291
|
-
@
|
|
1570
|
+
@_pos += 1
|
|
1292
1571
|
end
|
|
1293
|
-
@
|
|
1572
|
+
@_pos += 1 unless eof?
|
|
1294
1573
|
return
|
|
1295
1574
|
end
|
|
1296
1575
|
|
|
1297
1576
|
quote_char = byte
|
|
1298
|
-
@
|
|
1577
|
+
@_pos += 1 # Skip opening quote
|
|
1299
1578
|
|
|
1300
|
-
url_start = @
|
|
1579
|
+
url_start = @_pos
|
|
1301
1580
|
|
|
1302
1581
|
# Find closing quote (handle escaped quotes)
|
|
1303
1582
|
while !eof? && peek_byte != quote_char
|
|
1304
|
-
@
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1583
|
+
@_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
1584
|
+
2 # Skip escaped character
|
|
1585
|
+
else
|
|
1586
|
+
1
|
|
1587
|
+
end
|
|
1309
1588
|
end
|
|
1310
1589
|
|
|
1311
1590
|
if eof?
|
|
@@ -1313,87 +1592,227 @@ module Cataract
|
|
|
1313
1592
|
return
|
|
1314
1593
|
end
|
|
1315
1594
|
|
|
1316
|
-
url = byteslice_encoded(url_start, @
|
|
1317
|
-
@
|
|
1595
|
+
url = byteslice_encoded(url_start, @_pos - url_start)
|
|
1596
|
+
@_pos += 1 # Skip closing quote
|
|
1318
1597
|
|
|
1319
1598
|
# Skip closing paren if we had url(
|
|
1320
1599
|
if has_url_function
|
|
1321
1600
|
skip_ws_and_comments
|
|
1322
|
-
@
|
|
1601
|
+
@_pos += 1 if peek_byte == BYTE_RPAREN
|
|
1323
1602
|
end
|
|
1324
1603
|
|
|
1325
1604
|
skip_ws_and_comments
|
|
1326
1605
|
|
|
1327
1606
|
# Check for optional media query (everything until semicolon)
|
|
1328
|
-
|
|
1607
|
+
media_string = nil
|
|
1608
|
+
media_query_id = nil
|
|
1329
1609
|
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1330
|
-
media_start = @
|
|
1610
|
+
media_start = @_pos
|
|
1331
1611
|
|
|
1332
1612
|
# Find semicolon
|
|
1333
1613
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1334
|
-
@
|
|
1614
|
+
@_pos += 1
|
|
1335
1615
|
end
|
|
1336
1616
|
|
|
1337
|
-
media_end = @
|
|
1617
|
+
media_end = @_pos
|
|
1338
1618
|
|
|
1339
1619
|
# Trim trailing whitespace from media query
|
|
1340
|
-
while media_end > media_start && whitespace?(@
|
|
1620
|
+
while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
|
|
1341
1621
|
media_end -= 1
|
|
1342
1622
|
end
|
|
1343
1623
|
|
|
1344
1624
|
if media_end > media_start
|
|
1345
|
-
|
|
1625
|
+
media_string = byteslice_encoded(media_start, media_end - media_start)
|
|
1626
|
+
|
|
1627
|
+
# Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
|
|
1628
|
+
media_query_strings = media_string.split(',').map(&:strip)
|
|
1629
|
+
|
|
1630
|
+
# Create MediaQuery objects for each query in the list
|
|
1631
|
+
media_query_ids = []
|
|
1632
|
+
media_query_strings.each do |query_string|
|
|
1633
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1634
|
+
|
|
1635
|
+
# If we have a parent import's media context, combine them
|
|
1636
|
+
parent_import_type = @_parser_options[:parent_import_media_type]
|
|
1637
|
+
parent_import_conditions = @_parser_options[:parent_import_media_conditions]
|
|
1638
|
+
|
|
1639
|
+
if parent_import_type
|
|
1640
|
+
# Combine: parent's type is the effective type
|
|
1641
|
+
# Conditions are combined with "and"
|
|
1642
|
+
combined_type = parent_import_type
|
|
1643
|
+
combined_conditions = if parent_import_conditions && media_conditions
|
|
1644
|
+
"#{parent_import_conditions} and #{media_conditions}"
|
|
1645
|
+
elsif parent_import_conditions
|
|
1646
|
+
"#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
|
|
1647
|
+
elsif media_conditions
|
|
1648
|
+
media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
|
|
1649
|
+
else
|
|
1650
|
+
media_type == parent_import_type ? nil : media_type.to_s
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
media_type = combined_type
|
|
1654
|
+
media_conditions = combined_conditions
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
# Create MediaQuery object
|
|
1658
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1659
|
+
@media_queries << media_query
|
|
1660
|
+
media_query_ids << @_media_query_id_counter
|
|
1661
|
+
@_media_query_id_counter += 1
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# Use the first media query ID for the import statement
|
|
1665
|
+
# (The list is tracked separately for serialization)
|
|
1666
|
+
media_query_id = media_query_ids.first
|
|
1667
|
+
|
|
1668
|
+
# If multiple queries, track them as a list for serialization
|
|
1669
|
+
if media_query_ids.size > 1
|
|
1670
|
+
media_query_list_id = @_next_media_query_list_id
|
|
1671
|
+
@_media_query_lists[media_query_list_id] = media_query_ids
|
|
1672
|
+
@_next_media_query_list_id += 1
|
|
1673
|
+
end
|
|
1346
1674
|
end
|
|
1347
1675
|
end
|
|
1348
1676
|
|
|
1349
1677
|
# Skip semicolon
|
|
1350
|
-
@
|
|
1678
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1351
1679
|
|
|
1352
1680
|
# Create ImportStatement (resolved: false by default)
|
|
1353
|
-
import_stmt = ImportStatement.new(@
|
|
1681
|
+
import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
|
|
1354
1682
|
@imports << import_stmt
|
|
1355
|
-
@
|
|
1683
|
+
@_rule_id_counter += 1
|
|
1356
1684
|
end
|
|
1357
1685
|
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1686
|
+
# Convert relative URLs in a value string to absolute URLs
|
|
1687
|
+
# Called when @_absolute_paths is enabled and @_base_uri is set
|
|
1688
|
+
#
|
|
1689
|
+
# @param value [String] The declaration value to process
|
|
1690
|
+
# @return [String] Value with relative URLs converted to absolute
|
|
1691
|
+
def convert_urls_in_value(value)
|
|
1692
|
+
return value unless @_absolute_paths && @_base_uri
|
|
1693
|
+
|
|
1694
|
+
result = +''
|
|
1695
|
+
pos = 0
|
|
1696
|
+
len = value.bytesize
|
|
1697
|
+
|
|
1698
|
+
while pos < len
|
|
1699
|
+
# Look for 'url(' - case insensitive
|
|
1700
|
+
byte = value.getbyte(pos)
|
|
1701
|
+
if pos + 3 < len &&
|
|
1702
|
+
(byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
|
|
1703
|
+
(value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
|
|
1704
|
+
(value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
|
|
1705
|
+
value.getbyte(pos + 3) == BYTE_LPAREN
|
|
1706
|
+
|
|
1707
|
+
result << value.byteslice(pos, 4) # append 'url('
|
|
1708
|
+
pos += 4
|
|
1709
|
+
|
|
1710
|
+
# Skip whitespace
|
|
1711
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1712
|
+
result << value.getbyte(pos).chr
|
|
1713
|
+
pos += 1
|
|
1714
|
+
end
|
|
1367
1715
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1716
|
+
# Check for quote
|
|
1717
|
+
quote_char = nil
|
|
1718
|
+
if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
|
|
1719
|
+
quote_char = value.getbyte(pos)
|
|
1720
|
+
pos += 1
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
# Extract URL
|
|
1724
|
+
url_start = pos
|
|
1725
|
+
if quote_char
|
|
1726
|
+
# Scan until matching quote
|
|
1727
|
+
while pos < len && value.getbyte(pos) != quote_char
|
|
1728
|
+
# Handle escape
|
|
1729
|
+
pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
|
|
1730
|
+
2
|
|
1731
|
+
else
|
|
1732
|
+
1
|
|
1733
|
+
end
|
|
1734
|
+
end
|
|
1735
|
+
else
|
|
1736
|
+
# Scan until ) or whitespace
|
|
1737
|
+
while pos < len
|
|
1738
|
+
b = value.getbyte(pos)
|
|
1739
|
+
break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
|
|
1740
|
+
|
|
1741
|
+
pos += 1
|
|
1375
1742
|
end
|
|
1376
|
-
@pos += 1
|
|
1377
1743
|
end
|
|
1378
|
-
next
|
|
1379
|
-
end
|
|
1380
1744
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
# Check
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1745
|
+
url_str = value.byteslice(url_start, pos - url_start)
|
|
1746
|
+
|
|
1747
|
+
# Check if URL needs resolution (is relative)
|
|
1748
|
+
# Skip if: contains "://" OR starts with "data:"
|
|
1749
|
+
needs_resolution = true
|
|
1750
|
+
if url_str.empty?
|
|
1751
|
+
needs_resolution = false
|
|
1752
|
+
else
|
|
1753
|
+
# Check for "://"
|
|
1754
|
+
i = 0
|
|
1755
|
+
url_len = url_str.bytesize
|
|
1756
|
+
while i + 2 < url_len
|
|
1757
|
+
if url_str.getbyte(i) == BYTE_COLON &&
|
|
1758
|
+
url_str.getbyte(i + 1) == BYTE_SLASH &&
|
|
1759
|
+
url_str.getbyte(i + 2) == BYTE_SLASH
|
|
1760
|
+
needs_resolution = false
|
|
1761
|
+
break
|
|
1762
|
+
end
|
|
1763
|
+
i += 1
|
|
1764
|
+
end
|
|
1765
|
+
|
|
1766
|
+
# Check for "data:" prefix (case insensitive)
|
|
1767
|
+
if needs_resolution && url_len >= 5
|
|
1768
|
+
if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
|
|
1769
|
+
(url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
|
|
1770
|
+
(url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
|
|
1771
|
+
(url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
|
|
1772
|
+
url_str.getbyte(4) == BYTE_COLON
|
|
1773
|
+
needs_resolution = false
|
|
1774
|
+
end
|
|
1388
1775
|
end
|
|
1389
|
-
@pos += 1 unless eof? # Skip semicolon
|
|
1390
|
-
next
|
|
1391
1776
|
end
|
|
1392
|
-
end
|
|
1393
1777
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1778
|
+
if needs_resolution
|
|
1779
|
+
# Resolve relative URL using the resolver proc
|
|
1780
|
+
begin
|
|
1781
|
+
resolved = @_uri_resolver.call(@_base_uri, url_str)
|
|
1782
|
+
result << "'" << resolved << "'"
|
|
1783
|
+
rescue StandardError
|
|
1784
|
+
# If resolution fails, preserve original
|
|
1785
|
+
if quote_char
|
|
1786
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1787
|
+
else
|
|
1788
|
+
result << url_str
|
|
1789
|
+
end
|
|
1790
|
+
end
|
|
1791
|
+
elsif url_str.empty?
|
|
1792
|
+
# Preserve original URL
|
|
1793
|
+
result << "''"
|
|
1794
|
+
elsif quote_char
|
|
1795
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1796
|
+
else
|
|
1797
|
+
result << url_str
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
# Skip past closing quote if present
|
|
1801
|
+
pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
|
|
1802
|
+
|
|
1803
|
+
# Skip whitespace before )
|
|
1804
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1805
|
+
pos += 1
|
|
1806
|
+
end
|
|
1807
|
+
|
|
1808
|
+
# The ) will be copied in the next iteration or at the end
|
|
1809
|
+
else
|
|
1810
|
+
result << byte.chr
|
|
1811
|
+
pos += 1
|
|
1812
|
+
end
|
|
1396
1813
|
end
|
|
1814
|
+
|
|
1815
|
+
result
|
|
1397
1816
|
end
|
|
1398
1817
|
|
|
1399
1818
|
# Parse a block of declarations given start/end positions
|
|
@@ -1405,7 +1824,7 @@ module Cataract
|
|
|
1405
1824
|
|
|
1406
1825
|
while pos < end_pos
|
|
1407
1826
|
# Skip whitespace
|
|
1408
|
-
while pos < end_pos && whitespace?(@
|
|
1827
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1409
1828
|
pos += 1
|
|
1410
1829
|
end
|
|
1411
1830
|
break if pos >= end_pos
|
|
@@ -1417,5 +1836,115 @@ module Cataract
|
|
|
1417
1836
|
|
|
1418
1837
|
declarations
|
|
1419
1838
|
end
|
|
1839
|
+
|
|
1840
|
+
# Combine parent and child media query parts directly without string building
|
|
1841
|
+
#
|
|
1842
|
+
# The parent's type takes precedence (child type is ignored per CSS spec).
|
|
1843
|
+
#
|
|
1844
|
+
# @param parent_mq [MediaQuery] Parent media query object
|
|
1845
|
+
# @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
|
|
1846
|
+
# @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
|
|
1847
|
+
#
|
|
1848
|
+
# @example
|
|
1849
|
+
# combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
|
|
1850
|
+
def combine_media_query_parts(parent_mq, child_conditions)
|
|
1851
|
+
# Type: parent's type wins (outermost type)
|
|
1852
|
+
combined_type = parent_mq.type
|
|
1853
|
+
|
|
1854
|
+
# Conditions: combine parent and child conditions
|
|
1855
|
+
combined_conditions = if parent_mq.conditions && child_conditions
|
|
1856
|
+
"#{parent_mq.conditions} and #{child_conditions}"
|
|
1857
|
+
elsif parent_mq.conditions
|
|
1858
|
+
parent_mq.conditions
|
|
1859
|
+
elsif child_conditions
|
|
1860
|
+
child_conditions
|
|
1861
|
+
end
|
|
1862
|
+
|
|
1863
|
+
[combined_type, combined_conditions]
|
|
1864
|
+
end
|
|
1865
|
+
|
|
1866
|
+
# Parse media query string into type and conditions
|
|
1867
|
+
#
|
|
1868
|
+
# @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
|
|
1869
|
+
# @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
|
|
1870
|
+
#
|
|
1871
|
+
# @example
|
|
1872
|
+
# parse_media_query_parts("screen") #=> [:screen, nil]
|
|
1873
|
+
# parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
|
|
1874
|
+
# parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
|
|
1875
|
+
def parse_media_query_parts(query)
|
|
1876
|
+
i = 0
|
|
1877
|
+
len = query.bytesize
|
|
1878
|
+
|
|
1879
|
+
# Skip leading whitespace
|
|
1880
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1881
|
+
i += 1
|
|
1882
|
+
end
|
|
1883
|
+
|
|
1884
|
+
return [:all, nil] if i >= len
|
|
1885
|
+
|
|
1886
|
+
# Check if starts with '(' - media feature without type (defaults to :all)
|
|
1887
|
+
if query.getbyte(i) == BYTE_LPAREN
|
|
1888
|
+
return [:all, query.byteslice(i, len - i)]
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
# Find first media type word
|
|
1892
|
+
word_start = i
|
|
1893
|
+
while i < len
|
|
1894
|
+
byte = query.getbyte(i)
|
|
1895
|
+
break if whitespace?(byte) || byte == BYTE_LPAREN
|
|
1896
|
+
|
|
1897
|
+
i += 1
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1900
|
+
type = query.byteslice(word_start, i - word_start).to_sym
|
|
1901
|
+
|
|
1902
|
+
# Skip whitespace after type
|
|
1903
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1904
|
+
i += 1
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
# Check if there's more (conditions)
|
|
1908
|
+
if i >= len
|
|
1909
|
+
return [type, nil]
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
# Look for " and " keyword (case-insensitive)
|
|
1913
|
+
# We need to find "and" as a separate word
|
|
1914
|
+
and_pos = nil
|
|
1915
|
+
check_i = i
|
|
1916
|
+
while check_i < len - 2
|
|
1917
|
+
# Check for 'and' (a=97/65, n=110/78, d=100/68)
|
|
1918
|
+
byte0 = query.getbyte(check_i)
|
|
1919
|
+
byte1 = query.getbyte(check_i + 1)
|
|
1920
|
+
byte2 = query.getbyte(check_i + 2)
|
|
1921
|
+
|
|
1922
|
+
if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
|
|
1923
|
+
(byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
|
|
1924
|
+
(byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
|
|
1925
|
+
# Make sure it's a word boundary (whitespace before and after)
|
|
1926
|
+
before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
|
|
1927
|
+
after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
|
|
1928
|
+
if before_ok && after_ok
|
|
1929
|
+
and_pos = check_i
|
|
1930
|
+
break
|
|
1931
|
+
end
|
|
1932
|
+
end
|
|
1933
|
+
check_i += 1
|
|
1934
|
+
end
|
|
1935
|
+
|
|
1936
|
+
if and_pos
|
|
1937
|
+
# Skip past "and " to get conditions
|
|
1938
|
+
conditions_start = and_pos + 3 # skip "and"
|
|
1939
|
+
while conditions_start < len && whitespace?(query.getbyte(conditions_start))
|
|
1940
|
+
conditions_start += 1
|
|
1941
|
+
end
|
|
1942
|
+
conditions = query.byteslice(conditions_start, len - conditions_start)
|
|
1943
|
+
[type, conditions]
|
|
1944
|
+
else
|
|
1945
|
+
# No "and" found - rest is conditions (unusual but possible)
|
|
1946
|
+
[type, query.byteslice(i, len - i)]
|
|
1947
|
+
end
|
|
1948
|
+
end
|
|
1420
1949
|
end
|
|
1421
1950
|
end
|