p_css 0.2.0.beta1-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +282 -0
  3. data/Cargo.toml +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +357 -0
  6. data/ext/css_native/Cargo.toml +12 -0
  7. data/ext/css_native/extconf.rb +4 -0
  8. data/ext/css_native/src/lib.rs +117 -0
  9. data/ext/css_native/src/matcher.rs +356 -0
  10. data/ext/css_native/src/selectors.rs +411 -0
  11. data/ext/css_native/src/snapshot.rs +370 -0
  12. data/ext/css_native/src/state.rs +174 -0
  13. data/ext/css_native/src/tokenizer.rs +596 -0
  14. data/lib/css/3.3/css_native.so +0 -0
  15. data/lib/css/3.4/css_native.so +0 -0
  16. data/lib/css/4.0/css_native.so +0 -0
  17. data/lib/css/cascade.rb +277 -0
  18. data/lib/css/code_points.rb +59 -0
  19. data/lib/css/escape.rb +82 -0
  20. data/lib/css/media_queries/context.rb +60 -0
  21. data/lib/css/media_queries/evaluator.rb +157 -0
  22. data/lib/css/media_queries/nodes.rb +41 -0
  23. data/lib/css/media_queries/parser.rb +374 -0
  24. data/lib/css/media_queries.rb +9 -0
  25. data/lib/css/native.rb +179 -0
  26. data/lib/css/nesting.rb +229 -0
  27. data/lib/css/nodes.rb +42 -0
  28. data/lib/css/parser.rb +429 -0
  29. data/lib/css/selectors/anb_parser.rb +174 -0
  30. data/lib/css/selectors/matcher.rb +545 -0
  31. data/lib/css/selectors/nodes.rb +61 -0
  32. data/lib/css/selectors/parser.rb +395 -0
  33. data/lib/css/selectors/serializer.rb +102 -0
  34. data/lib/css/selectors/specificity.rb +81 -0
  35. data/lib/css/selectors.rb +11 -0
  36. data/lib/css/serializer.rb +167 -0
  37. data/lib/css/token.rb +107 -0
  38. data/lib/css/token_cursor.rb +49 -0
  39. data/lib/css/tokenizer.rb +447 -0
  40. data/lib/css/urange.rb +45 -0
  41. data/lib/css/version.rb +3 -0
  42. data/lib/css.rb +73 -0
  43. data/lib/p_css.rb +1 -0
  44. data/sig/css/cascade.rbs +22 -0
  45. data/sig/css/media_queries.rbs +107 -0
  46. data/sig/css/nodes.rbs +76 -0
  47. data/sig/css/selectors.rbs +164 -0
  48. data/sig/css/token.rbs +33 -0
  49. data/sig/css.rbs +99 -0
  50. metadata +113 -0
@@ -0,0 +1,374 @@
1
+ module CSS
2
+ module MediaQueries
3
+ # Parser for `<media-query-list>` per Media Queries Level 4 §3.
4
+ # https://drafts.csswg.org/mediaqueries-4/
5
+ #
6
+ # Accepts either a String (which is tokenized and re-component-valued
7
+ # so `(...)` becomes a `SimpleBlock`) or an Array of component values
8
+ # (for use against an `@media` rule's prelude from the main parser).
9
+ class Parser
10
+ include CSS::TokenCursor
11
+
12
+ MODIFIER_KEYWORDS = %w[not only].freeze
13
+ LOGICAL_KEYWORDS = %w[and or not].freeze
14
+
15
+ class << self
16
+ def parse(input)
17
+ new(items_from(input)).parse_media_query_list
18
+ end
19
+
20
+ private
21
+
22
+ def items_from(input)
23
+ input.is_a?(String) ? CSS::Parser.parse_component_values(input) : input.to_a
24
+ end
25
+ end
26
+
27
+ def initialize(items)
28
+ init_cursor(items)
29
+ end
30
+
31
+ def parse_media_query_list
32
+ skip_whitespace
33
+
34
+ queries = [parse_media_query]
35
+
36
+ loop do
37
+ skip_whitespace
38
+ break unless peek_token.type == :comma
39
+
40
+ consume
41
+ queries << parse_media_query
42
+ end
43
+
44
+ skip_whitespace
45
+
46
+ unless eof?
47
+ parse_error!("trailing tokens after media query list: #{describe(peek)}")
48
+ end
49
+
50
+ MediaQueryList.new(queries:)
51
+ end
52
+
53
+ def parse_media_query
54
+ skip_whitespace
55
+
56
+ saved = @pos
57
+
58
+ # Try the `[not | only]? <media-type> [and <condition-without-or>]?` form.
59
+ modifier = consume_modifier
60
+ skip_whitespace if modifier
61
+
62
+ if (item = peek).is_a?(Token) && item.type == :ident && !LOGICAL_KEYWORDS.include?(item.value.downcase)
63
+ type = consume.value.downcase
64
+ skip_whitespace
65
+
66
+ condition = nil
67
+
68
+ if keyword?('and')
69
+ consume
70
+ skip_whitespace
71
+ condition = parse_media_condition(allow_or: false)
72
+ end
73
+
74
+ return MediaQuery.new(modifier:, type:, condition:)
75
+ end
76
+
77
+ # Otherwise this is a pure media-condition (no type / modifier).
78
+ @pos = saved
79
+
80
+ MediaQuery.new(modifier: nil, type: nil, condition: parse_media_condition(allow_or: true))
81
+ end
82
+
83
+ private
84
+
85
+ def parse_media_condition(allow_or:)
86
+ skip_whitespace
87
+
88
+ if keyword?('not')
89
+ consume
90
+ skip_whitespace
91
+ return MediaNot.new(operand: parse_media_in_parens)
92
+ end
93
+
94
+ first = parse_media_in_parens
95
+
96
+ skip_whitespace
97
+
98
+ if keyword?('and')
99
+ operands = [first]
100
+ while keyword?('and')
101
+ consume
102
+ skip_whitespace
103
+ operands << parse_media_in_parens
104
+ skip_whitespace
105
+ end
106
+ MediaAnd.new(operands:)
107
+ elsif allow_or && keyword?('or')
108
+ operands = [first]
109
+ while keyword?('or')
110
+ consume
111
+ skip_whitespace
112
+ operands << parse_media_in_parens
113
+ skip_whitespace
114
+ end
115
+ MediaOr.new(operands:)
116
+ else
117
+ first
118
+ end
119
+ end
120
+
121
+ def parse_media_in_parens
122
+ item = peek
123
+
124
+ unless item.is_a?(Nodes::SimpleBlock) && item.parenthesized?
125
+ parse_error!("expected '(', got #{describe(item)}")
126
+ end
127
+
128
+ consume
129
+ inner = self.class.new(item.value)
130
+ inner.parse_in_parens_contents
131
+ end
132
+
133
+ protected
134
+
135
+ # Called on a sub-parser whose @items is the contents inside `(...)`.
136
+ # Returns a media-condition or a feature.
137
+ def parse_in_parens_contents
138
+ skip_whitespace
139
+
140
+ return GeneralEnclosed.new(tokens: []) if eof?
141
+
142
+ # Nested `(condition)`?
143
+ first = peek
144
+
145
+ if first.is_a?(Nodes::SimpleBlock) && first.parenthesized?
146
+ cond = parse_media_condition(allow_or: true)
147
+ skip_whitespace
148
+
149
+ unless eof?
150
+ return GeneralEnclosed.new(tokens: @items)
151
+ end
152
+
153
+ return cond
154
+ end
155
+
156
+ result = try_parse_feature
157
+
158
+ return result if result
159
+
160
+ GeneralEnclosed.new(tokens: @items)
161
+ end
162
+
163
+ private
164
+
165
+ def try_parse_feature
166
+ saved = @pos
167
+
168
+ starting_token = peek
169
+
170
+ if starting_token.is_a?(Token) && starting_token.type == :ident
171
+ feature = try_parse_feature_starting_with_ident
172
+
173
+ return feature if feature
174
+
175
+ @pos = saved
176
+ end
177
+
178
+ if value_starts?(starting_token)
179
+ feature = try_parse_feature_starting_with_value
180
+
181
+ return feature if feature
182
+
183
+ @pos = saved
184
+ end
185
+
186
+ nil
187
+ end
188
+
189
+ def try_parse_feature_starting_with_ident
190
+ name = consume.value.downcase
191
+
192
+ skip_whitespace
193
+
194
+ if eof?
195
+ return MediaFeature.new(name:, op: nil, value: nil)
196
+ end
197
+
198
+ if peek_token.type == :colon
199
+ consume
200
+ skip_whitespace
201
+ value = parse_mf_value
202
+
203
+ return nil if value.nil?
204
+
205
+ skip_whitespace
206
+
207
+ return nil unless eof?
208
+
209
+ return MediaFeature.new(name:, op: :eq, value:)
210
+ end
211
+
212
+ if (op = consume_comparison)
213
+ skip_whitespace
214
+ value = parse_mf_value
215
+
216
+ return nil if value.nil?
217
+
218
+ skip_whitespace
219
+
220
+ if eof?
221
+ return MediaFeature.new(name:, op:, value:)
222
+ end
223
+
224
+ # Bounded form: `<name> <op> <value> ... <op> <value>`
225
+ # Per spec, bounded form has the name in the middle, not here.
226
+ # Reject.
227
+ return nil
228
+ end
229
+
230
+ nil
231
+ end
232
+
233
+ def try_parse_feature_starting_with_value
234
+ first_value = parse_mf_value
235
+
236
+ return nil if first_value.nil?
237
+
238
+ skip_whitespace
239
+
240
+ first_op = consume_comparison
241
+
242
+ return nil unless first_op
243
+
244
+ skip_whitespace
245
+
246
+ return nil unless peek_token.type == :ident
247
+
248
+ name = consume.value.downcase
249
+
250
+ skip_whitespace
251
+
252
+ if eof?
253
+ return MediaFeature.new(name:, op: invert_op(first_op), value: first_value)
254
+ end
255
+
256
+ # Bounded form: <value> <op1> <name> <op2> <value>
257
+ second_op = consume_comparison
258
+
259
+ return nil unless second_op
260
+
261
+ skip_whitespace
262
+
263
+ second_value = parse_mf_value
264
+
265
+ return nil if second_value.nil?
266
+
267
+ skip_whitespace
268
+
269
+ return nil unless eof?
270
+
271
+ # Decompose into MediaAnd of two normalized features.
272
+ MediaAnd.new(operands: [
273
+ MediaFeature.new(name:, op: invert_op(first_op), value: first_value),
274
+ MediaFeature.new(name:, op: second_op, value: second_value)
275
+ ])
276
+ end
277
+
278
+ # `value op name` swaps to `name (inverted op) value`.
279
+ INVERSE_OP = {lt: :gt, le: :ge, gt: :lt, ge: :le, eq: :eq}.freeze
280
+
281
+ def invert_op(op) = INVERSE_OP.fetch(op, op)
282
+
283
+ def parse_mf_value
284
+ item = peek
285
+
286
+ return nil unless item.is_a?(Token)
287
+
288
+ case item.type
289
+ when :number
290
+ consume
291
+
292
+ if peek_token.type == :delim && peek_token.value == '/'
293
+ consume
294
+ skip_whitespace
295
+ denom = peek
296
+
297
+ return nil unless denom.is_a?(Token) && denom.type == :number
298
+
299
+ consume
300
+ return Ratio.new(numerator: item.value, denominator: denom.value)
301
+ end
302
+
303
+ item
304
+ when :dimension, :percentage, :ident, :string
305
+ consume
306
+ item
307
+ else
308
+ nil
309
+ end
310
+ end
311
+
312
+ def consume_comparison
313
+ item = peek
314
+
315
+ return nil unless item.is_a?(Token) && item.type == :delim
316
+
317
+ case item.value
318
+ when '='
319
+ consume
320
+ :eq
321
+ when '<'
322
+ consume
323
+
324
+ if peek_token.type == :delim && peek_token.value == '='
325
+ consume
326
+ :le
327
+ else
328
+ :lt
329
+ end
330
+ when '>'
331
+ consume
332
+
333
+ if peek_token.type == :delim && peek_token.value == '='
334
+ consume
335
+ :ge
336
+ else
337
+ :gt
338
+ end
339
+ end
340
+ end
341
+
342
+ def value_starts?(item)
343
+ item.is_a?(Token) && %i[number dimension percentage].include?(item.type)
344
+ end
345
+
346
+ def consume_modifier
347
+ item = peek
348
+
349
+ return nil unless item.is_a?(Token) && item.type == :ident
350
+
351
+ kw = item.value.downcase
352
+
353
+ return nil unless MODIFIER_KEYWORDS.include?(kw)
354
+
355
+ consume
356
+ kw.to_sym
357
+ end
358
+
359
+ def keyword?(kw)
360
+ item = peek
361
+ item.is_a?(Token) && item.type == :ident && item.value.downcase == kw
362
+ end
363
+
364
+ def describe(item)
365
+ case item
366
+ when Token then item.type
367
+ when Nodes::SimpleBlock then "#{item.open}-block"
368
+ when Nodes::Function then "#{item.name}()"
369
+ else item.class.name
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'media_queries/nodes'
2
+ require_relative 'media_queries/parser'
3
+ require_relative 'media_queries/context'
4
+ require_relative 'media_queries/evaluator'
5
+
6
+ module CSS
7
+ module MediaQueries
8
+ end
9
+ end
data/lib/css/native.rb ADDED
@@ -0,0 +1,179 @@
1
+ require_relative '../css'
2
+
3
+ # cibuildgem ships precompiled binaries at lib/css/<ruby_minor>/css_native.{so,bundle,dll}.
4
+ # When users compile the gem from source (no platform gem available),
5
+ # extconf.rb places the binary at lib/css/css_native.{so,bundle,dll}.
6
+ # Try the version-specific path first, fall back to the non-versioned one.
7
+ begin
8
+ ruby_minor = RUBY_VERSION[/\d+\.\d+/]
9
+ require "css/#{ruby_minor}/css_native"
10
+ rescue LoadError
11
+ require_relative 'css_native'
12
+ end
13
+
14
+ # Rust defines matches?, matches_any?, match_indices with a required
15
+ # `state` argument (3 args). Wrap them in Ruby so callers can pass 2
16
+ # args when stateful pseudos aren't relevant.
17
+ module CSS
18
+ module Native
19
+ class Snapshot
20
+ alias_method :_native_matches?, :matches?
21
+ alias_method :_native_matches_any?, :matches_any?
22
+ alias_method :_native_match_indices, :match_indices
23
+
24
+ def matches?(element, selector, state = nil)
25
+ _native_matches?(element, selector, state)
26
+ end
27
+
28
+ def matches_any?(element, selectors, state = nil)
29
+ _native_matches_any?(element, selectors, state)
30
+ end
31
+
32
+ def match_indices(element, selectors, state = nil)
33
+ _native_match_indices(element, selectors, state)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ module CSS
40
+ module Native
41
+ # High-level wrapper: takes a Nokogiri element + a selector (string or
42
+ # parsed AST) and returns matches?(element). Falls back to the pure-Ruby
43
+ # Matcher when the selector contains features the native matcher doesn't
44
+ # support yet (pseudo-classes, :not, etc.).
45
+ #
46
+ # `document:` / `snapshot:` let callers control snapshot reuse across
47
+ # many matches against the same DOM. With neither, a per-document
48
+ # snapshot is cached by document identity.
49
+ class << self
50
+ def matches?(element, selector, snapshot: nil, document: nil, state: nil)
51
+ ast = selector.is_a?(String) ? CSS.parse_selector_list(selector) : selector
52
+ compiled = compile_or_nil(ast)
53
+ snap = snapshot || snapshot_for(document || element.document)
54
+
55
+ return CSS.matches?(element, ast, state: state) unless compiled
56
+
57
+ native_state = state.nil? ? nil : (state.is_a?(State) ? state : snap.compile_state(state))
58
+ snap.matches?(element, compiled, native_state)
59
+ end
60
+
61
+ def compile_or_nil(ast)
62
+ Selector.compile(ast)
63
+ rescue Unsupported
64
+ nil
65
+ end
66
+
67
+ private
68
+
69
+ def snapshot_for(document)
70
+ (@snapshots ||= {}.compare_by_identity)[document] ||= Snapshot.from_document(document)
71
+ end
72
+ end
73
+
74
+ # Subclass of CSS::Cascade that uses the native matcher for the inner
75
+ # rule-matching loop. Selectors are pre-compiled at construction —
76
+ # those that can't be compiled (pseudo-classes etc.) fall through to
77
+ # the pure-Ruby matcher, so behavior is identical to CSS::Cascade.
78
+ #
79
+ # Requires a Nokogiri document at construction; the snapshot is built
80
+ # once and reused for every resolve(). Mutate the DOM and you must
81
+ # construct a fresh CSS::Native::Cascade.
82
+ class Cascade < CSS::Cascade
83
+ def initialize(stylesheet, document, context: CSS::MediaQueries::Context.default)
84
+ super(stylesheet, context: context)
85
+
86
+ @snapshot = Snapshot.from_document(document)
87
+ @compiled_by_ast = {}.compare_by_identity
88
+
89
+ @entries.each do |entry|
90
+ entry.selector_pairs.each {|ast, _spec|
91
+ @compiled_by_ast[ast] = Native.compile_or_nil(ast)
92
+ }
93
+ end
94
+ end
95
+
96
+ # Override: batch every candidate's compiled selectors into one FFI
97
+ # hop per resolve (GVL released), then merge in any Ruby-fallback
98
+ # matches. Cuts per-resolve FFI cost from O(candidates) to O(1).
99
+ def resolve(element, inline_style: nil, state: nil)
100
+ cache = {}
101
+ candidates = collect_candidate_indexes(element, cache)
102
+ order = 0
103
+ matches = []
104
+ native_state = state && @snapshot.compile_state(state)
105
+
106
+ best_by_entry = native_pass(element, candidates, native_state)
107
+ ruby_fallback_pass(element, candidates, best_by_entry, cache, state)
108
+
109
+ candidates.each do |entry_idx|
110
+ spec = best_by_entry[entry_idx] or next
111
+
112
+ @entries[entry_idx].declarations.each {|decl|
113
+ order += 1
114
+ matches << CSS::Cascade::Match.new(declaration: decl, specificity: spec, inline: false, order: order)
115
+ }
116
+ end
117
+
118
+ if inline_style
119
+ inline_declarations(inline_style).each {|decl|
120
+ order += 1
121
+ matches << CSS::Cascade::Match.new(
122
+ declaration: decl,
123
+ specificity: CSS::Selectors::Specificity::ZERO,
124
+ inline: true,
125
+ order: order
126
+ )
127
+ }
128
+ end
129
+
130
+ pick_winners(matches)
131
+ end
132
+
133
+ private
134
+
135
+ # Flatten the candidates' compiled selectors into one batched
136
+ # match_indices call. Returns Hash{entry_idx => best_specificity}.
137
+ def native_pass(element, candidates, native_state)
138
+ positions = []
139
+ sels = []
140
+
141
+ candidates.each do |entry_idx|
142
+ @entries[entry_idx].selector_pairs.each {|ast, spec|
143
+ compiled = @compiled_by_ast[ast] or next
144
+
145
+ positions << [entry_idx, spec]
146
+ sels << compiled
147
+ }
148
+ end
149
+
150
+ best_by_entry = {}
151
+
152
+ return best_by_entry if sels.empty?
153
+
154
+ @snapshot.match_indices(element, sels, native_state).each {|i|
155
+ entry_idx, spec = positions[i]
156
+ cur = best_by_entry[entry_idx]
157
+
158
+ best_by_entry[entry_idx] = spec if cur.nil? || spec > cur
159
+ }
160
+
161
+ best_by_entry
162
+ end
163
+
164
+ # Run pure-Ruby matching for any selectors that didn't compile
165
+ # (pseudo-classes, etc.), merging results into best_by_entry.
166
+ def ruby_fallback_pass(element, candidates, best_by_entry, cache, state)
167
+ candidates.each do |entry_idx|
168
+ @entries[entry_idx].selector_pairs.each {|ast, spec|
169
+ next if @compiled_by_ast[ast]
170
+ next unless Selectors::Matcher.matches?(element, ast, cache: cache, state: state)
171
+
172
+ cur = best_by_entry[entry_idx]
173
+ best_by_entry[entry_idx] = spec if cur.nil? || spec > cur
174
+ }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end