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,277 @@
1
+ module CSS
2
+ # Resolves the cascade for a Stylesheet against a single element. Returns
3
+ # `Hash<String, Declaration>` keyed by property name with the winning
4
+ # declaration after applying:
5
+ #
6
+ # - `@media` filtering (against a `MediaQueries::Context`)
7
+ # - selector matching (`Selectors::Matcher`)
8
+ # - cascade sort: `!important` > origin / inline > specificity > source order
9
+ #
10
+ # The Stylesheet is compiled once on construction (selectors are
11
+ # pre-parsed, specificities pre-computed, `@media` chains are
12
+ # evaluated against the supplied context up-front, and rules are
13
+ # indexed by the rightmost compound's strongest anchor — id > class >
14
+ # tag > universal). `resolve(element)` then visits only the rules whose
15
+ # anchor could match the element.
16
+ #
17
+ # Cascade layers, `@scope` proximity, and Shadow DOM encapsulation are
18
+ # not modeled — `@layer`, `@supports`, `@container`, `@scope`, and
19
+ # `@starting-style` blocks are descended into unconditionally.
20
+ class Cascade
21
+ Match = Data.define(:declaration, :specificity, :inline, :order)
22
+
23
+ RuleEntry = Data.define(:selector_pairs, :declarations)
24
+
25
+ TRANSPARENT_AT_RULES = %w[supports layer scope starting-style container].freeze
26
+
27
+ def initialize(stylesheet, context: MediaQueries::Context.default)
28
+ @context = context
29
+ @entries = compile(stylesheet)
30
+ @index = build_index(@entries)
31
+ end
32
+
33
+ # Returns Hash<String, Declaration> of winning declarations.
34
+ #
35
+ # `state:` opts into stateful-pseudo matching — see
36
+ # `Selectors::Matcher#matches?` for the shape. Defaults to the
37
+ # stateless behavior (`:hover`, `:focus`, etc. never match).
38
+ #
39
+ # `cache:` lets callers share a per-element context cache across many
40
+ # resolves. The default `{}` is local to one call. Pass a persistent
41
+ # Hash when the DOM is stable across many resolves — Context (tag, id,
42
+ # classes) computation runs once per element instead of per resolve.
43
+ # The caller is responsible for clearing/replacing the cache on DOM
44
+ # mutation.
45
+ def resolve(element, inline_style: nil, state: nil, cache: nil)
46
+ cache ||= {}
47
+ candidates = collect_candidate_indexes(element, cache)
48
+ order = 0
49
+ matches = []
50
+
51
+ candidates.each do |idx|
52
+ entry = @entries[idx]
53
+ spec = best_matching_specificity(element, entry.selector_pairs, cache, state)
54
+
55
+ next if spec.nil?
56
+
57
+ entry.declarations.each do |decl|
58
+ order += 1
59
+ matches << Match.new(declaration: decl, specificity: spec, inline: false, order: order)
60
+ end
61
+ end
62
+
63
+ if inline_style
64
+ inline_declarations(inline_style).each do |decl|
65
+ order += 1
66
+ matches << Match.new(declaration: decl, specificity: Selectors::Specificity::ZERO, inline: true, order: order)
67
+ end
68
+ end
69
+
70
+ pick_winners(matches)
71
+ end
72
+
73
+ private
74
+
75
+ # Compile
76
+ # ----------------------------------------------------------------
77
+
78
+ def compile(stylesheet)
79
+ out = []
80
+ walk(stylesheet.rules, [], out)
81
+ out
82
+ end
83
+
84
+ # Filters the stylesheet down to rules whose `@media` chain (if any)
85
+ # matches the cascade's context, pre-parsing every selector list and
86
+ # caching its specificity per selector.
87
+ def walk(rules, media_chain, out)
88
+ rules.each do |rule|
89
+ case rule
90
+ when Nodes::QualifiedRule
91
+ register_qualified_rule(rule, media_chain, out)
92
+ when Nodes::AtRule
93
+ dispatch_at_rule(rule, media_chain, out)
94
+ end
95
+ end
96
+ end
97
+
98
+ def register_qualified_rule(rule, media_chain, out)
99
+ return unless media_chain.all? { MediaQueries::Evaluator.evaluate(_1, @context) }
100
+
101
+ sl = Selectors::Parser.parse_selector_list(rule.prelude)
102
+ pairs = sl.selectors.map { [_1, Selectors::SpecificityCalculator.calculate(_1)] }
103
+ decls = rule.block.items.select { _1.is_a?(Nodes::Declaration) }
104
+
105
+ out << RuleEntry.new(selector_pairs: pairs, declarations: decls)
106
+ rescue ParseError
107
+ # Browsers drop a rule whose prelude doesn't parse as a selector
108
+ # list rather than poisoning the whole stylesheet; do the same.
109
+ end
110
+
111
+ def dispatch_at_rule(rule, media_chain, out)
112
+ return unless rule.block
113
+
114
+ case rule.name.downcase
115
+ when 'media'
116
+ ql = MediaQueries::Parser.parse(rule.prelude)
117
+ walk(rule.block.items, [*media_chain, ql], out)
118
+ when *TRANSPARENT_AT_RULES
119
+ walk(rule.block.items, media_chain, out)
120
+ end
121
+ rescue ParseError
122
+ # Bad media prelude → skip this @media block; rules outside it
123
+ # remain unaffected.
124
+ end
125
+
126
+ # Index
127
+ # ----------------------------------------------------------------
128
+
129
+ Index = Data.define(:by_id, :by_class, :by_tag, :universal)
130
+ AnchorKey = Data.define(:kind, :name)
131
+
132
+ def build_index(entries)
133
+ by_id = {}
134
+ by_class = {}
135
+ by_tag = {}
136
+ universal = []
137
+
138
+ entries.each_with_index do |entry, idx|
139
+ seen = Set.new
140
+
141
+ entry.selector_pairs.each do |sel, _spec|
142
+ key = anchor_key(sel)
143
+
144
+ next if seen.include?(key)
145
+
146
+ seen << key
147
+
148
+ case key.kind
149
+ when :id then (by_id[key.name] ||= []) << idx
150
+ when :class then (by_class[key.name] ||= []) << idx
151
+ when :tag then (by_tag[key.name] ||= []) << idx
152
+ when :universal then universal << idx
153
+ end
154
+ end
155
+ end
156
+
157
+ Index.new(
158
+ by_id: by_id.freeze,
159
+ by_class: by_class.freeze,
160
+ by_tag: by_tag.freeze,
161
+ universal: universal.freeze
162
+ )
163
+ end
164
+
165
+ # Picks the strongest anchor in the rightmost compound: id > class >
166
+ # tag > universal. Compounds whose only simple selectors are pseudos
167
+ # (e.g. `:hover`) or attribute matchers fall through to universal —
168
+ # they will be tested against every element, but real-world
169
+ # stylesheets rarely have many such rules.
170
+ def anchor_key(complex_selector)
171
+ class_name = nil
172
+ tag_name = nil
173
+
174
+ complex_selector.compounds.last.components.each do |c|
175
+ case c
176
+ when Selectors::IdSelector then return AnchorKey.new(kind: :id, name: c.name)
177
+ when Selectors::ClassSelector then class_name ||= c.name
178
+ when Selectors::TypeSelector then tag_name ||= c.name.downcase
179
+ end
180
+ end
181
+
182
+ return AnchorKey.new(kind: :class, name: class_name) if class_name
183
+ return AnchorKey.new(kind: :tag, name: tag_name) if tag_name
184
+
185
+ AnchorKey.new(kind: :universal, name: nil)
186
+ end
187
+
188
+ # Resolve helpers
189
+ # ----------------------------------------------------------------
190
+
191
+ # Buckets are appended in source-order at compile time, so each is
192
+ # already sorted ascending and unique. Concatenating them and using
193
+ # `sort! + uniq!` is cheaper than going through a `Set`: integers
194
+ # sort in C, and `uniq!` on a sorted array only removes adjacent
195
+ # duplicates.
196
+ def collect_candidate_indexes(element, cache)
197
+ out = []
198
+
199
+ el_id = Selectors::Matcher.id_of(element, cache)
200
+ bucket = @index.by_id[el_id] if el_id
201
+
202
+ out.concat(bucket) if bucket
203
+
204
+ Selectors::Matcher.classes_of(element, cache).each do |cls|
205
+ bucket = @index.by_class[cls]
206
+ out.concat(bucket) if bucket
207
+ end
208
+
209
+ bucket = @index.by_tag[Selectors::Matcher.tag_of(element, cache)]
210
+ out.concat(bucket) if bucket
211
+
212
+ out.concat(@index.universal)
213
+ out.sort!
214
+ out.uniq!
215
+ out
216
+ end
217
+
218
+ def best_matching_specificity(element, selector_pairs, cache, state)
219
+ best = nil
220
+
221
+ selector_pairs.each do |sel, spec|
222
+ next unless Selectors::Matcher.matches?(element, sel, cache: cache, state: state)
223
+
224
+ best = spec if best.nil? || spec > best
225
+ end
226
+
227
+ best
228
+ end
229
+
230
+ # Single-pass running max per property name. Cheaper than group_by +
231
+ # max_by, and avoids allocating a fresh comparison key per
232
+ # declaration.
233
+ def pick_winners(matches)
234
+ best = {}
235
+
236
+ matches.each do |m|
237
+ name = m.declaration.name
238
+ incumbent = best[name]
239
+
240
+ best[name] = m if incumbent.nil? || better?(m, incumbent)
241
+ end
242
+
243
+ best.transform_values(&:declaration)
244
+ end
245
+
246
+ # `m` outranks `incumbent` when its priority class is higher, or — at
247
+ # the same priority class — its specificity is greater, or — at equal
248
+ # specificity — it appeared later in source order.
249
+ def better?(m, incumbent)
250
+ a = priority(m)
251
+ b = priority(incumbent)
252
+ return a > b unless a == b
253
+
254
+ cmp = m.specificity <=> incumbent.specificity
255
+ return cmp.positive? unless cmp.zero?
256
+
257
+ m.order > incumbent.order
258
+ end
259
+
260
+ # !important and inline style each bump the rule into a higher
261
+ # priority class. Encoded so that `priority(a) <=> priority(b)`
262
+ # captures the cascade's origin/importance ordering.
263
+ def priority(m)
264
+ (m.declaration.important ? 2 : 0) + (m.inline ? 1 : 0)
265
+ end
266
+
267
+ def inline_declarations(style)
268
+ case style
269
+ when String then CSS.parse_block_contents(style).items.select { _1.is_a?(Nodes::Declaration) }
270
+ when Nodes::Block then style.items.select { _1.is_a?(Nodes::Declaration) }
271
+ when Array then style.select { _1.is_a?(Nodes::Declaration) }
272
+ else
273
+ raise ArgumentError, "cannot derive inline declarations from #{style.class}"
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,59 @@
1
+ module CSS
2
+ # Character class predicates from CSS Syntax §4.2 Definitions, plus the
3
+ # U+FFFD replacement character used both during tokenization and
4
+ # serialization.
5
+ #
6
+ # ASCII bytes are looked up in a precomputed boolean table (one Array
7
+ # access + one branch); non-ASCII code points (>= 0x80) are always
8
+ # ident-cp / ident-start per spec, so the helpers fall back to a single
9
+ # `c.ord >= 0x80` check. Avoids the chain of `String#<=>` calls a
10
+ # range-style predicate would dispatch.
11
+ module CodePoints
12
+ REPLACEMENT = "�".freeze
13
+
14
+ def self.build_table(*ranges_or_ints)
15
+ Array.new(128, false).tap {|a|
16
+ ranges_or_ints.each {|r|
17
+ if r.is_a?(Range) then r.each { a[_1] = true }
18
+ else a[r] = true
19
+ end
20
+ }
21
+ }.freeze
22
+ end
23
+
24
+ DIGIT_TABLE = build_table(0x30..0x39)
25
+ HEX_DIGIT_TABLE = build_table(0x30..0x39, 0x41..0x46, 0x61..0x66)
26
+ IDENT_START_TABLE = build_table(0x41..0x5A, 0x61..0x7A, 0x5F)
27
+ IDENT_CP_TABLE = build_table(0x30..0x39, 0x41..0x5A, 0x61..0x7A, 0x5F, 0x2D)
28
+
29
+ module_function
30
+
31
+ def digit?(c)
32
+ return false if c.nil?
33
+
34
+ o = c.ord
35
+ o < 128 && DIGIT_TABLE[o]
36
+ end
37
+
38
+ def hex_digit?(c)
39
+ return false if c.nil?
40
+
41
+ o = c.ord
42
+ o < 128 && HEX_DIGIT_TABLE[o]
43
+ end
44
+
45
+ def ident_start_code_point?(c)
46
+ return false if c.nil?
47
+
48
+ o = c.ord
49
+ o >= 128 || IDENT_START_TABLE[o]
50
+ end
51
+
52
+ def ident_code_point?(c)
53
+ return false if c.nil?
54
+
55
+ o = c.ord
56
+ o >= 128 || IDENT_CP_TABLE[o]
57
+ end
58
+ end
59
+ end
data/lib/css/escape.rb ADDED
@@ -0,0 +1,82 @@
1
+ module CSS
2
+ # CSS Syntax §9.3 escape primitives — `serialize an identifier`,
3
+ # `serialize a name`, and `serialize a string`. Reused by both the main
4
+ # serializer and the selector serializer.
5
+ module Escape
6
+ extend self
7
+ extend CodePoints
8
+
9
+ # §9.3.1.
10
+ def ident(ident)
11
+ buf = +''
12
+ lone_dash = ident.length == 1 && ident == '-'
13
+ hyphen0 = ident.start_with?('-')
14
+
15
+ ident.each_char.with_index {|c, i|
16
+ cp = c.ord
17
+
18
+ if (esc = control_or_nul(cp))
19
+ buf << esc
20
+ elsif i.zero? && lone_dash
21
+ buf << '\\-'
22
+ elsif (i.zero? && digit?(c)) || (i == 1 && hyphen0 && digit?(c))
23
+ buf << format('\\%x ', cp)
24
+ elsif ident_code_point?(c)
25
+ buf << c
26
+ else
27
+ buf << "\\#{c}"
28
+ end
29
+ }
30
+
31
+ buf
32
+ end
33
+
34
+ # §9.3 "Serialize a name". Like an ident but allows leading digits
35
+ # and hyphens — used for unrestricted hash tokens.
36
+ def name(name)
37
+ buf = +''
38
+
39
+ name.each_char {|c|
40
+ cp = c.ord
41
+
42
+ if (esc = control_or_nul(cp))
43
+ buf << esc
44
+ elsif ident_code_point?(c)
45
+ buf << c
46
+ else
47
+ buf << "\\#{c}"
48
+ end
49
+ }
50
+
51
+ buf
52
+ end
53
+
54
+ # §9.3.2. Always uses double quotes.
55
+ def string(s)
56
+ buf = +'"'
57
+
58
+ s.each_char {|c|
59
+ cp = c.ord
60
+
61
+ if (esc = control_or_nul(cp))
62
+ buf << esc
63
+ elsif c == '"' || c == '\\'
64
+ buf << "\\#{c}"
65
+ else
66
+ buf << c
67
+ end
68
+ }
69
+
70
+ buf << '"'
71
+ end
72
+
73
+ # NUL collapses to U+FFFD; controls (0x01..0x1F, 0x7F) get hex
74
+ # escapes. Returns nil for non-control code points.
75
+ def control_or_nul(cp)
76
+ return CodePoints::REPLACEMENT if cp.zero?
77
+ return format('\\%x ', cp) if (0x01..0x1F).cover?(cp) || cp == 0x7F
78
+
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ module CSS
2
+ module MediaQueries
3
+ # Holds the user-agent context against which a MediaQueryList is
4
+ # evaluated. Stored as a feature → value Hash; values follow Media
5
+ # Queries Level 4 conventions:
6
+ #
7
+ # - lengths in CSS pixels (Numeric)
8
+ # - resolution in dots-per-CSS-px (`dppx`, Numeric)
9
+ # - identifier-valued features as Strings ("landscape", "dark", ...)
10
+ # - boolean-style features as 1 / 0 or true / false
11
+ #
12
+ # `Context.default(**overrides)` returns a sensible desktop preset.
13
+ Context = Data.define(:features) do
14
+ def [](name) = features[name.to_s]
15
+
16
+ def media_type = self['media-type']
17
+
18
+ def with(**overrides)
19
+ Context.new(features: features.merge(overrides.transform_keys(&:to_s)))
20
+ end
21
+
22
+ def self.default(**overrides)
23
+ new(features: DEFAULTS.merge(overrides.transform_keys(&:to_s)))
24
+ end
25
+
26
+ DEFAULTS = {
27
+ 'media-type' => 'screen',
28
+
29
+ 'width' => 1024,
30
+ 'height' => 768,
31
+ 'device-width' => 1024,
32
+ 'device-height' => 768,
33
+ 'aspect-ratio' => 1024.0 / 768,
34
+ 'device-aspect-ratio' => 1024.0 / 768,
35
+ 'orientation' => 'landscape',
36
+
37
+ 'resolution' => 1, # dppx
38
+ 'color' => 8,
39
+ 'color-gamut' => 'srgb',
40
+ 'color-index' => 0,
41
+ 'monochrome' => 0,
42
+ 'grid' => 0,
43
+ 'scan' => 'progressive',
44
+ 'update' => 'fast',
45
+ 'overflow-block' => 'scroll',
46
+ 'overflow-inline' => 'scroll',
47
+
48
+ 'pointer' => 'fine',
49
+ 'hover' => 'hover',
50
+ 'any-pointer' => 'fine',
51
+ 'any-hover' => 'hover',
52
+
53
+ 'prefers-color-scheme' => 'light',
54
+ 'prefers-reduced-motion' => 'no-preference',
55
+ 'prefers-contrast' => 'no-preference',
56
+ 'forced-colors' => 'none'
57
+ }.freeze
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,157 @@
1
+ module CSS
2
+ module MediaQueries
3
+ # Evaluates a MediaQueryList against a Context, returning true if at
4
+ # least one media-query in the list matches.
5
+ module Evaluator
6
+ extend self
7
+
8
+ # Length conversion to CSS px assumes 1em = 1rem = 16px. Per Media
9
+ # Queries Level 4 §1.3 this is the conventional fallback when the
10
+ # font-size of the root is unknown.
11
+ EM_PX = 16.0
12
+
13
+ LENGTH_UNITS_PX = {
14
+ 'px' => 1.0,
15
+ 'em' => EM_PX,
16
+ 'rem' => EM_PX,
17
+ 'ex' => EM_PX * 0.5,
18
+ 'ch' => EM_PX * 0.5,
19
+ 'pt' => 96.0 / 72,
20
+ 'pc' => 16.0,
21
+ 'in' => 96.0,
22
+ 'cm' => 96.0 / 2.54,
23
+ 'mm' => 96.0 / 25.4,
24
+ 'q' => 96.0 / 25.4 / 4
25
+ }.freeze
26
+
27
+ RESOLUTION_UNITS_DPPX = {
28
+ 'dppx' => 1.0,
29
+ 'x' => 1.0,
30
+ 'dpi' => 1.0 / 96,
31
+ 'dpcm' => 2.54 / 96
32
+ }.freeze
33
+
34
+ RESOLUTION_FEATURES = %w[resolution].freeze
35
+
36
+ INVERSE_OP = {lt: :gt, le: :ge, gt: :lt, ge: :le, eq: :eq}.freeze
37
+
38
+ PREFIX_OP = {min: :ge, max: :le}.freeze
39
+
40
+ def evaluate(query_list, context)
41
+ query_list.queries.any? { evaluate_query(_1, context) }
42
+ end
43
+
44
+ private
45
+
46
+ def evaluate_query(query, context)
47
+ result = evaluate_query_main(query, context)
48
+ query.modifier == :not ? !result : result
49
+ end
50
+
51
+ def evaluate_query_main(query, context)
52
+ if query.type
53
+ return false unless type_matches?(query.type, context.media_type)
54
+ end
55
+
56
+ return true if query.condition.nil?
57
+
58
+ evaluate_condition(query.condition, context)
59
+ end
60
+
61
+ def type_matches?(type, ctx_type)
62
+ type == 'all' || type == ctx_type.to_s
63
+ end
64
+
65
+ def evaluate_condition(node, context)
66
+ case node
67
+ when MediaNot then !evaluate_condition(node.operand, context)
68
+ when MediaAnd then node.operands.all? { evaluate_condition(_1, context) }
69
+ when MediaOr then node.operands.any? { evaluate_condition(_1, context) }
70
+ when MediaFeature then evaluate_feature(node, context)
71
+ when GeneralEnclosed then false
72
+ else false
73
+ end
74
+ end
75
+
76
+ def evaluate_feature(feature, context)
77
+ ctx_name, prefix = strip_prefix(feature.name)
78
+ ctx_value = context[ctx_name]
79
+
80
+ return evaluate_boolean(ctx_value) if feature.op.nil?
81
+
82
+ compare(prefix, feature.op, ctx_value, feature.value, ctx_name)
83
+ end
84
+
85
+ def evaluate_boolean(ctx_value)
86
+ return false if ctx_value.nil?
87
+ return false if ctx_value == 0 || ctx_value == false || ctx_value == '' || ctx_value == 'none'
88
+
89
+ true
90
+ end
91
+
92
+ def strip_prefix(name)
93
+ case name
94
+ when /\Amin-(.+)/ then [$1, :min]
95
+ when /\Amax-(.+)/ then [$1, :max]
96
+ else [name, nil]
97
+ end
98
+ end
99
+
100
+ def compare(prefix, op, ctx_value, feature_value, ctx_name)
101
+ op = PREFIX_OP[prefix] || op
102
+
103
+ return string_op_apply(op, ctx_value.to_s, feature_value.value.to_s) if ident_compare?(feature_value)
104
+
105
+ a = numeric_for(ctx_name, ctx_value)
106
+ b = numeric_for(ctx_name, feature_value)
107
+
108
+ return false if a.nil? || b.nil?
109
+
110
+ numeric_op_apply(op, a, b)
111
+ end
112
+
113
+ def ident_compare?(feature_value)
114
+ feature_value.is_a?(Token) && feature_value.type == :ident
115
+ end
116
+
117
+ def string_op_apply(op, a, b)
118
+ op == :eq && a.casecmp?(b)
119
+ end
120
+
121
+ def numeric_op_apply(op, a, b)
122
+ case op
123
+ when :eq then a == b
124
+ when :lt then a < b
125
+ when :le then a <= b
126
+ when :gt then a > b
127
+ when :ge then a >= b
128
+ end
129
+ end
130
+
131
+ # Converts both context value and feature value to a comparable
132
+ # numeric in the canonical unit for the named feature.
133
+ def numeric_for(ctx_name, value)
134
+ case value
135
+ when Numeric then value.to_f
136
+ when Ratio then value.to_f
137
+ when Token
138
+ case value.type
139
+ when :number then value.value.to_f
140
+ when :percentage then value.value.to_f / 100
141
+ when :dimension then dimension_to_canonical(value, ctx_name)
142
+ else nil
143
+ end
144
+ else nil
145
+ end
146
+ end
147
+
148
+ def dimension_to_canonical(token, ctx_name)
149
+ unit = token.unit.downcase
150
+ table = RESOLUTION_FEATURES.include?(ctx_name) ? RESOLUTION_UNITS_DPPX : LENGTH_UNITS_PX
151
+
152
+ factor = table[unit]
153
+ factor && token.value.to_f * factor
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,41 @@
1
+ module CSS
2
+ module MediaQueries
3
+ # Marker module for media-query AST nodes; lets the main serializer
4
+ # dispatch into MediaQueries::Serializer when it ever exists.
5
+ module Node; end
6
+
7
+ MediaQueryList = Data.define(:queries) do
8
+ include Node
9
+ end
10
+
11
+ # `modifier` is `nil`, `:not`, or `:only`.
12
+ # `type` is `nil` or a downcased string ('screen', 'print', 'all', ...).
13
+ # `condition` is `nil` or a media-condition node.
14
+ MediaQuery = Data.define(:modifier, :type, :condition) do
15
+ include Node
16
+ end
17
+
18
+ MediaNot = Data.define(:operand) { include Node }
19
+ MediaAnd = Data.define(:operands) { include Node }
20
+ MediaOr = Data.define(:operands) { include Node }
21
+
22
+ # `op` is `nil` (boolean form, e.g. `(color)`), `:eq` (plain form,
23
+ # `(min-width: 600px)`, or range `=`), `:lt`, `:le`, `:gt`, or `:ge`.
24
+ # `value` is `nil` (boolean), a Token, or a Ratio.
25
+ MediaFeature = Data.define(:name, :op, :value) do
26
+ include Node
27
+ end
28
+
29
+ # Catch-all for `(...)` content the parser couldn't recognize as a
30
+ # feature or condition. Preserved so downstream tools can still see it.
31
+ GeneralEnclosed = Data.define(:tokens) do
32
+ include Node
33
+ end
34
+
35
+ # Numeric ratio used in `aspect-ratio` / `device-aspect-ratio` features.
36
+ Ratio = Data.define(:numerator, :denominator) do
37
+ include Node
38
+ def to_f = numerator.to_f / denominator
39
+ end
40
+ end
41
+ end