p_css 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ module CSS
2
+ # Serializer based on CSS Syntax Module Level 4 §9 Serialization.
3
+ # https://drafts.csswg.org/css-syntax/#serialization
4
+ #
5
+ # The output is intended to round-trip: re-parsing it should yield an
6
+ # equivalent AST. Idents, strings, hashes, and dimensions are escaped
7
+ # following the spec rules.
8
+ module Serializer
9
+ extend self
10
+
11
+ INDENT = ' '.freeze
12
+
13
+ def serialize(node)
14
+ case node
15
+ when Nodes::Stylesheet then serialize_stylesheet(node)
16
+ when Nodes::AtRule then serialize_at_rule(node)
17
+ when Nodes::QualifiedRule then serialize_qualified_rule(node)
18
+ when Nodes::Block then serialize_block(node)
19
+ when Nodes::Declaration then serialize_declaration(node)
20
+ when Nodes::Function then serialize_function(node)
21
+ when Nodes::SimpleBlock then serialize_simple_block(node)
22
+ when Token then serialize_token(node)
23
+ when Selectors::Node then Selectors::Serializer.serialize(node)
24
+ when Array then node.map { serialize(it) }.join
25
+ else
26
+ raise ArgumentError, "cannot serialize #{node.class}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def serialize_stylesheet(ss)
33
+ ss.rules.map { serialize(it) }.join("\n")
34
+ end
35
+
36
+ def serialize_at_rule(rule)
37
+ head = "@#{serialize_ident(rule.name)}"
38
+
39
+ prelude_str = serialize(rule.prelude)
40
+ head += " #{prelude_str}" unless prelude_str.empty?
41
+
42
+ rule.block ? "#{head} #{serialize_block(rule.block)}" : "#{head};"
43
+ end
44
+
45
+ def serialize_qualified_rule(rule)
46
+ "#{serialize(rule.prelude)} #{serialize_block(rule.block)}"
47
+ end
48
+
49
+ def serialize_block(block)
50
+ return '{}' if block.items.empty?
51
+
52
+ inner = block.items.map { serialize(it) }.join("\n")
53
+ "{\n#{indent(inner)}\n}"
54
+ end
55
+
56
+ def serialize_declaration(decl)
57
+ important = decl.important ? ' !important' : ''
58
+ "#{serialize_ident(decl.name)}: #{serialize(decl.value)}#{important};"
59
+ end
60
+
61
+ def serialize_function(fn)
62
+ "#{serialize_ident(fn.name)}(#{serialize(fn.value)})"
63
+ end
64
+
65
+ def serialize_simple_block(block)
66
+ "#{block.open}#{serialize(block.value)}#{BRACKET_PAIRS.fetch(block.open)}"
67
+ end
68
+
69
+ # §9.3.
70
+ def serialize_token(t)
71
+ case t.type
72
+ when :ident then serialize_ident(t.value)
73
+ when :function then "#{serialize_ident(t.value)}("
74
+ when :at_keyword then "@#{serialize_ident(t.value)}"
75
+ when :hash then serialize_hash(t)
76
+ when :string then serialize_string(t.value)
77
+ when :url then "url(#{serialize_string(t.value)})"
78
+ when :bad_string, :bad_url then ''
79
+ when :delim then t.value
80
+ when :number then serialize_number(t.value, t.flag)
81
+ when :percentage then "#{serialize_number(t.value, :integer)}%"
82
+ when :dimension then serialize_dimension(t)
83
+ when :whitespace then ' '
84
+ when :comment then "/*#{t.value}*/"
85
+ when :cdo then '<!--'
86
+ when :cdc then '-->'
87
+ when :colon then ':'
88
+ when :semicolon then ';'
89
+ when :comma then ','
90
+ when :lbracket then '['
91
+ when :rbracket then ']'
92
+ when :lparen then '('
93
+ when :rparen then ')'
94
+ when :lbrace then '{'
95
+ when :rbrace then '}'
96
+ when :eof then ''
97
+ end
98
+ end
99
+
100
+ def serialize_hash(t)
101
+ t.flag == :id ? "##{serialize_ident(t.value)}" : "##{serialize_name(t.value)}"
102
+ end
103
+
104
+ def serialize_dimension(t)
105
+ "#{serialize_number(t.value, t.flag)}#{serialize_dimension_unit(t.unit)}"
106
+ end
107
+
108
+ # If a unit starts with `e[+-]?<digit>`, the leading `e` would re-merge
109
+ # into the number's exponent on re-tokenization. Escape it.
110
+ def serialize_dimension_unit(unit)
111
+ if unit.match?(/\A[eE](?:[+-]?\d)/)
112
+ "\\#{format('%X', unit[0].ord)} #{unit[1..]}"
113
+ else
114
+ serialize_ident(unit)
115
+ end
116
+ end
117
+
118
+ # §9.3.6. Avoids E-notation entirely.
119
+ def serialize_number(value, flag)
120
+ case
121
+ when value.is_a?(Integer) && flag != :number
122
+ value.to_s
123
+ when value.is_a?(Integer)
124
+ "#{value}.0"
125
+ when value.finite?
126
+ format_finite_float(value)
127
+ else
128
+ '0'
129
+ end
130
+ end
131
+
132
+ def format_finite_float(f)
133
+ s = f.to_s
134
+ return s unless s.match?(/[eE]/)
135
+
136
+ sign = f.negative? ? '-' : ''
137
+ mantissa, e = s.sub(/\A-/, '').split(/[eE]/)
138
+ e = e.to_i
139
+ whole, frac = mantissa.split('.')
140
+ frac ||= ''
141
+
142
+ digits = whole + frac
143
+ decimal = whole.length + e
144
+
145
+ body =
146
+ if decimal <= 0
147
+ '0.' + ('0' * -decimal) + digits.sub(/0+\z/, '')
148
+ elsif decimal >= digits.length
149
+ digits + ('0' * (decimal - digits.length)) + '.0'
150
+ else
151
+ "#{digits[0, decimal]}.#{digits[decimal..]}"
152
+ end
153
+
154
+ "#{sign}#{body.sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '.0')}"
155
+ end
156
+
157
+ def serialize_ident(s) = Escape.ident(s)
158
+ def serialize_name(s) = Escape.name(s)
159
+ def serialize_string(s) = Escape.string(s)
160
+
161
+ def indent(str)
162
+ str.lines.map { "#{INDENT}#{it}" }.join.then {
163
+ it.end_with?("\n") ? it.chomp : it
164
+ }
165
+ end
166
+ end
167
+ end
data/lib/css/token.rb ADDED
@@ -0,0 +1,78 @@
1
+ module CSS
2
+ # Source location of a token within the preprocessed input. `offset` and
3
+ # `end_offset` are 0-based character indices; `line` and `column` are
4
+ # 1-based.
5
+ Position = Data.define(:line, :column, :offset, :end_offset) do
6
+ def to_s
7
+ "#{line}:#{column}"
8
+ end
9
+ end
10
+
11
+ class Token
12
+ TYPES = %i[
13
+ ident function at_keyword hash string bad_string url bad_url
14
+ delim number percentage dimension whitespace cdo cdc comment
15
+ colon semicolon comma
16
+ lbracket rbracket lparen rparen lbrace rbrace
17
+ eof
18
+ ].freeze
19
+
20
+ attr_reader :type, :value, :flag, :unit, :position
21
+
22
+ def initialize(type, value = nil, flag: nil, unit: nil, position: nil)
23
+ raise ArgumentError, "unknown token type: #{type.inspect}" unless TYPES.include?(type)
24
+
25
+ @type = type
26
+ @value = value
27
+ @flag = flag
28
+ @unit = unit
29
+ @position = position
30
+ end
31
+
32
+ # Position is intentionally excluded from equality so that hand-built
33
+ # tokens compare equal to parsed tokens.
34
+ def ==(other)
35
+ other.is_a?(Token) &&
36
+ other.type == type &&
37
+ other.value == value &&
38
+ other.flag == flag &&
39
+ other.unit == unit
40
+ end
41
+ alias eql? ==
42
+
43
+ def hash
44
+ [type, value, flag, unit].hash
45
+ end
46
+
47
+ def whitespace?
48
+ type == :whitespace
49
+ end
50
+
51
+ def comment?
52
+ type == :comment
53
+ end
54
+
55
+ # True for tokens that don't carry semantic content — used by the parser
56
+ # to skip insignificant tokens between meaningful ones.
57
+ def trivia?
58
+ type == :whitespace || type == :comment
59
+ end
60
+
61
+ # Mutating: assigns the token's source position and returns self. Used
62
+ # by the tokenizer so each token requires only a single allocation.
63
+ def assign_position!(pos)
64
+ @position = pos
65
+ self
66
+ end
67
+
68
+ def inspect
69
+ parts = ["type=#{type.inspect}"]
70
+ parts << "value=#{value.inspect}" unless value.nil?
71
+ parts << "flag=#{flag.inspect}" unless flag.nil?
72
+ parts << "unit=#{unit.inspect}" unless unit.nil?
73
+ parts << "@#{position}" unless position.nil?
74
+
75
+ "#<CSS::Token #{parts.join(' ')}>"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,49 @@
1
+ module CSS
2
+ # Shared cursor used by the three parsers (`Selectors::Parser`,
3
+ # `Selectors::AnBParser::Impl`, `MediaQueries::Parser`). Each one walks
4
+ # an array of items with the same primitives — tokens for the selector
5
+ # parsers, mixed Token / SimpleBlock / Function items for the media-
6
+ # query parser. Predicates against the item's `.type` go through
7
+ # `peek_token`, which collapses non-Token items to EOF safely.
8
+ module TokenCursor
9
+ EOF_TOKEN = Token.new(:eof).freeze
10
+
11
+ def init_cursor(items)
12
+ @items = items
13
+ @pos = 0
14
+ end
15
+
16
+ def peek(offset = 0)
17
+ @items[@pos + offset] || EOF_TOKEN
18
+ end
19
+
20
+ # Returns peek unwrapped to a Token; non-Token items collapse to
21
+ # EOF. Lets media-query code do `.type == :colon` against streams
22
+ # that may also hold SimpleBlock / Function items.
23
+ def peek_token
24
+ item = peek
25
+ item.is_a?(Token) ? item : EOF_TOKEN
26
+ end
27
+
28
+ def consume
29
+ item = @items[@pos] || EOF_TOKEN
30
+ @pos += 1
31
+ item
32
+ end
33
+
34
+ def skip_whitespace
35
+ while (item = peek).is_a?(Token) && item.type == :whitespace
36
+ @pos += 1
37
+ end
38
+ end
39
+
40
+ def eof?
41
+ @pos >= @items.length
42
+ end
43
+
44
+ def parse_error!(message)
45
+ pos = peek.respond_to?(:position) ? peek.position : nil
46
+ raise ParseError.new(message, position: pos)
47
+ end
48
+ end
49
+ end