caps 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.
- checksums.yaml +7 -0
- data/.editorconfig +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +47 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +72 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/caps/errors.rb +6 -0
- data/lib/caps/parser/consumers.rb +273 -0
- data/lib/caps/parser/entrypoints.rb +114 -0
- data/lib/caps/parser/helpers.rb +96 -0
- data/lib/caps/parser/infra.rb +54 -0
- data/lib/caps/parser.rb +14 -0
- data/lib/caps/tokenizer/helpers.rb +69 -0
- data/lib/caps/tokenizer/infra.rb +163 -0
- data/lib/caps/tokenizer/location.rb +25 -0
- data/lib/caps/tokenizer.rb +464 -0
- data/lib/caps/version.rb +5 -0
- data/lib/caps.rb +9 -0
- data/tokens.json +1 -0
- metadata +68 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Caps
|
|
4
|
+
class Parser
|
|
5
|
+
module Entrypoints
|
|
6
|
+
using Caps::Parser::Helpers
|
|
7
|
+
|
|
8
|
+
def parse_full_sheet
|
|
9
|
+
base = parse_stylesheet
|
|
10
|
+
base[:value].map! do |obj|
|
|
11
|
+
if obj[:type] == :qualified_rule
|
|
12
|
+
p = Parser.new(obj.dig(:block, :value))
|
|
13
|
+
obj[:block][:value] = p.parse_style_block_contents
|
|
14
|
+
elsif obj[:type] == :at_rule && obj.dig(:name, :value).downcase == "font-face"
|
|
15
|
+
p = Parser.new(obj.dig(:block, :value))
|
|
16
|
+
obj[:block][:value] = p.parse_declaration_list
|
|
17
|
+
end
|
|
18
|
+
obj
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
base
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse_stylesheet(location: nil)
|
|
25
|
+
{
|
|
26
|
+
type: :stylesheet,
|
|
27
|
+
location:,
|
|
28
|
+
value: consume_list_of_rules(top_level: true)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_rule_list
|
|
33
|
+
consume_list_of_rules(top_level: false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_rule
|
|
37
|
+
consume_whitespace
|
|
38
|
+
val = peek
|
|
39
|
+
if val.eof?
|
|
40
|
+
# EOF. Raise syntax error.
|
|
41
|
+
syntax_error! "Unexpected EOF"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
ret_val = if val.at_rule?
|
|
45
|
+
consume_at_rule
|
|
46
|
+
else
|
|
47
|
+
consume_qualified_rule
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
consume_whitespace
|
|
51
|
+
|
|
52
|
+
syntax_error! "Expected EOF, found #{peek.type} instead" unless peek.eof?
|
|
53
|
+
|
|
54
|
+
ret_val
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_declaration
|
|
58
|
+
consume_whitespace
|
|
59
|
+
syntax_error! "Unexpected #{peek.type}, expected ident" unless peek.ident?
|
|
60
|
+
|
|
61
|
+
consume_declaration.tap do |decl|
|
|
62
|
+
syntax_error! "Expected declaration to be consumed" if decl.nil?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_style_block_contents
|
|
67
|
+
consume_style_block_contents
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_declaration_list
|
|
71
|
+
consume_declaration_list
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_component_value
|
|
75
|
+
consume_whitespace
|
|
76
|
+
syntax_error! "Unexpected EOF" if peek.eof?
|
|
77
|
+
|
|
78
|
+
ret_val = consume_component_value
|
|
79
|
+
consume_whitespace
|
|
80
|
+
return ret_val if peek.eof?
|
|
81
|
+
|
|
82
|
+
syntax_error! "Expected EOF"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_component_value_list
|
|
86
|
+
arr = []
|
|
87
|
+
loop do
|
|
88
|
+
obj = consume_component_value
|
|
89
|
+
break if obj.eof?
|
|
90
|
+
|
|
91
|
+
arr << obj
|
|
92
|
+
end
|
|
93
|
+
arr
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_comma_separated_component_values
|
|
97
|
+
cvls = []
|
|
98
|
+
|
|
99
|
+
loop do
|
|
100
|
+
cv = consume_component_value
|
|
101
|
+
if cv.comma?
|
|
102
|
+
next
|
|
103
|
+
elsif cv.eof?
|
|
104
|
+
break
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
cvls << cv
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
cvls
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Caps
|
|
4
|
+
class Parser
|
|
5
|
+
module Helpers
|
|
6
|
+
refine Hash do
|
|
7
|
+
def self.helper(name, &)
|
|
8
|
+
define_method(name, &)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.tassert(tname)
|
|
12
|
+
helper("#{tname}?".to_sym) { type == tname }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start_line
|
|
16
|
+
dig(:position, :start, :line)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start_column
|
|
20
|
+
dig(:position, :start, :column)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def end_column
|
|
24
|
+
dig(:position, :end, :column)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def end_line
|
|
28
|
+
dig(:position, :end, :line)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def type
|
|
32
|
+
self[:type]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def value
|
|
36
|
+
self[:value]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tassert(:eof)
|
|
40
|
+
tassert(:ident)
|
|
41
|
+
tassert(:whitespace)
|
|
42
|
+
tassert(:at_keyword)
|
|
43
|
+
tassert(:comma)
|
|
44
|
+
tassert(:semicolon)
|
|
45
|
+
tassert(:left_curly)
|
|
46
|
+
tassert(:delim)
|
|
47
|
+
tassert(:colon)
|
|
48
|
+
tassert(:bang)
|
|
49
|
+
tassert(:left_square)
|
|
50
|
+
tassert(:right_square)
|
|
51
|
+
tassert(:left_parens)
|
|
52
|
+
tassert(:right_parens)
|
|
53
|
+
tassert(:function)
|
|
54
|
+
tassert(:at_rule)
|
|
55
|
+
tassert(:cdo)
|
|
56
|
+
tassert(:cdc)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
refine NilClass do
|
|
60
|
+
def self.helper(name, &)
|
|
61
|
+
define_method(name, &)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.tassert(tname)
|
|
65
|
+
helper("#{tname}?") { false }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def type
|
|
69
|
+
:eof
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def eof?
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
tassert(:ident)
|
|
77
|
+
tassert(:whitespace)
|
|
78
|
+
tassert(:at_keyword)
|
|
79
|
+
tassert(:comma)
|
|
80
|
+
tassert(:semicolon)
|
|
81
|
+
tassert(:left_curly)
|
|
82
|
+
tassert(:delim)
|
|
83
|
+
tassert(:colon)
|
|
84
|
+
tassert(:bang)
|
|
85
|
+
tassert(:left_square)
|
|
86
|
+
tassert(:right_square)
|
|
87
|
+
tassert(:left_parens)
|
|
88
|
+
tassert(:right_square)
|
|
89
|
+
tassert(:function)
|
|
90
|
+
tassert(:at_rule)
|
|
91
|
+
tassert(:cdo)
|
|
92
|
+
tassert(:cdc)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module Caps
|
|
6
|
+
class Parser
|
|
7
|
+
using Caps::Parser::Helpers
|
|
8
|
+
|
|
9
|
+
def initialize(tokens)
|
|
10
|
+
@tokens = tokens
|
|
11
|
+
@idx = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def peek
|
|
15
|
+
return nil if @idx >= @tokens.length
|
|
16
|
+
|
|
17
|
+
@tokens[@idx]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def prev
|
|
21
|
+
return @tokens.first if @idx.zero?
|
|
22
|
+
|
|
23
|
+
@tokens[@idx - 1]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def advance
|
|
27
|
+
return nil if peek.nil?
|
|
28
|
+
|
|
29
|
+
peek.tap { @idx += 1 }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def consume_whitespace
|
|
33
|
+
advance while peek.whitespace?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tracking
|
|
37
|
+
start = peek.dig(:position, :start)
|
|
38
|
+
result = yield
|
|
39
|
+
finish = prev.dig(:position, :end)
|
|
40
|
+
result.merge({
|
|
41
|
+
position: {
|
|
42
|
+
start:,
|
|
43
|
+
end: finish
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def syntax_error!(reason)
|
|
49
|
+
token = peek.nil? ? prev : peek
|
|
50
|
+
raise "Syntax Error: #{reason} at line #{token.end_line} " \
|
|
51
|
+
"column #{token.end_column}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/caps/parser.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser/infra"
|
|
4
|
+
require_relative "parser/helpers"
|
|
5
|
+
require_relative "parser/entrypoints"
|
|
6
|
+
require_relative "parser/consumers"
|
|
7
|
+
|
|
8
|
+
module Caps
|
|
9
|
+
class Parser
|
|
10
|
+
using Caps::Parser::Helpers
|
|
11
|
+
include Caps::Parser::Consumers
|
|
12
|
+
include Caps::Parser::Entrypoints
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Caps
|
|
4
|
+
class Tokenizer
|
|
5
|
+
module Helpers
|
|
6
|
+
refine String do
|
|
7
|
+
def self.helper(name, &)
|
|
8
|
+
define_method(name, &)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
helper(:uni) { unpack1("U") }
|
|
12
|
+
helper(:between?) { |a, b| uni >= a && uni <= b }
|
|
13
|
+
helper(:digit?) { between? 0x30, 0x39 }
|
|
14
|
+
helper(:hex?) { digit? || between?(0x41, 0x46) || between?(0x61, 0x66) }
|
|
15
|
+
helper(:uppercase?) { between? 0x41, 0x5a }
|
|
16
|
+
helper(:lowercase?) { between? 0x61, 0x7a }
|
|
17
|
+
helper(:letter?) { uppercase? || lowercase? }
|
|
18
|
+
|
|
19
|
+
def non_ascii?
|
|
20
|
+
uni == 0xb7 ||
|
|
21
|
+
between?(0xc0, 0xd6) ||
|
|
22
|
+
between?(0xd8, 0xf6) ||
|
|
23
|
+
between?(0xf8, 0x37d) ||
|
|
24
|
+
between?(0x37f, 0x1fff) ||
|
|
25
|
+
uni == 0x200c ||
|
|
26
|
+
uni == 0x200d ||
|
|
27
|
+
uni == 0x203f ||
|
|
28
|
+
uni == 0x2040 ||
|
|
29
|
+
between?(0x2070, 0x218f) ||
|
|
30
|
+
between?(0x2c00, 0x2fef) ||
|
|
31
|
+
between?(0x3001, 0xd7ff) ||
|
|
32
|
+
between?(0xf900, 0xfdcf) ||
|
|
33
|
+
between?(0xfdf0, 0xfffd) ||
|
|
34
|
+
uni >= 0x10000
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
helper(:ident_start?) { letter? || non_ascii? || uni == 0x5f }
|
|
38
|
+
helper(:ident_char?) { ident_start? || digit? || uni == 0x2d }
|
|
39
|
+
helper(:non_printable?) { between?(0x00, 0x08) || uni == 0xb || between?(0xe, 0x1f) || uni == 0x7f }
|
|
40
|
+
helper(:newline?) { uni == 0xa }
|
|
41
|
+
helper(:whitespace?) { newline? || uni == 0x9 || uni == 0x20 }
|
|
42
|
+
helper(:bad_escape?) { newline? }
|
|
43
|
+
helper(:surrogate?) { between?(0xd800, 0xdfff) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
refine NilClass do
|
|
47
|
+
def self.helper(name, &)
|
|
48
|
+
define_method(name, &)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
helper(:uni) { 0x00 }
|
|
52
|
+
helper(:between?) { |_a, _b| false }
|
|
53
|
+
helper(:digit?) { false }
|
|
54
|
+
helper(:hex?) { false }
|
|
55
|
+
helper(:uppercase?) { false }
|
|
56
|
+
helper(:lowercase?) { false }
|
|
57
|
+
helper(:letter?) { false }
|
|
58
|
+
helper(:non_ascii?) { false }
|
|
59
|
+
helper(:ident_start?) { false }
|
|
60
|
+
helper(:ident_char?) { false }
|
|
61
|
+
helper(:non_printable?) { false }
|
|
62
|
+
helper(:newline?) { false }
|
|
63
|
+
helper(:whitespace?) { false }
|
|
64
|
+
helper(:bad_escape?) { false }
|
|
65
|
+
helper(:surrogate?) { false }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Caps
|
|
4
|
+
class Tokenizer
|
|
5
|
+
using Caps::Tokenizer::Helpers
|
|
6
|
+
attr_accessor :contents, :tokens
|
|
7
|
+
|
|
8
|
+
def self.parse(src)
|
|
9
|
+
new(src).parse!
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(contents)
|
|
13
|
+
@contents = self.class.preprocess(contents)
|
|
14
|
+
setup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse!
|
|
18
|
+
consume_token until eof?
|
|
19
|
+
@tokens
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def pos
|
|
23
|
+
{ idx: @idx, line: @line, column: @column }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def self.preprocess(contents)
|
|
29
|
+
codepoints = []
|
|
30
|
+
clusters = contents.grapheme_clusters
|
|
31
|
+
i = 0
|
|
32
|
+
until i == clusters.length
|
|
33
|
+
code = clusters[i]
|
|
34
|
+
|
|
35
|
+
if code.uni == 0x0d && clusters[i + 1].uni == 0x0a
|
|
36
|
+
codepoints << LINE_FEED
|
|
37
|
+
i += 1
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
code = LINE_FEED if code.uni == 0x0d || code.uni == 0x0c
|
|
42
|
+
|
|
43
|
+
code = REPLACEMENT_CHARACTER if code.between?(0xd800, 0xdbff) || code.between?(0xdc00, 0xdfff) || code.uni.zero?
|
|
44
|
+
|
|
45
|
+
codepoints << code
|
|
46
|
+
i += 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
codepoints
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def setup
|
|
53
|
+
@idx = 0
|
|
54
|
+
@len = @contents.length
|
|
55
|
+
@line = 1
|
|
56
|
+
@column = 1
|
|
57
|
+
@last_line_length = 0
|
|
58
|
+
@tokens = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def eof?
|
|
62
|
+
@idx >= @contents.length
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def peek(qty = 0)
|
|
66
|
+
return nil if @idx + qty >= @len
|
|
67
|
+
|
|
68
|
+
@contents[@idx + qty]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def peek1
|
|
72
|
+
peek(1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def peek2
|
|
76
|
+
peek(2)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def advance
|
|
80
|
+
chr = @contents[@idx]
|
|
81
|
+
@idx += 1
|
|
82
|
+
if @idx > @len
|
|
83
|
+
raise "[BUG] Over-read! @idx=#{@idx} @len=#{@len} #advance without #peek or #eof?\n#{Thread.current.backtrace.reject do |v|
|
|
84
|
+
v.start_with?("/opt") || v.start_with?("/usr")
|
|
85
|
+
end.join("\n")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return chr if eof?
|
|
89
|
+
|
|
90
|
+
if @contents[@idx].newline?
|
|
91
|
+
@line += 1
|
|
92
|
+
@last_line_length = @column
|
|
93
|
+
@column = 1
|
|
94
|
+
else
|
|
95
|
+
@column += 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
chr
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def scoped
|
|
102
|
+
start = @idx
|
|
103
|
+
yield
|
|
104
|
+
@contents[start...@idx]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def isolated
|
|
108
|
+
old = pos
|
|
109
|
+
begin
|
|
110
|
+
yield
|
|
111
|
+
ensure
|
|
112
|
+
@idx = old[:idx]
|
|
113
|
+
@line = old[:line]
|
|
114
|
+
@column = old[:column]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def mark_pos
|
|
119
|
+
Location.new(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def pack_while(type)
|
|
123
|
+
start = mark_pos
|
|
124
|
+
data = scoped do
|
|
125
|
+
loop do
|
|
126
|
+
break if !yield || eof?
|
|
127
|
+
|
|
128
|
+
advance
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
start.push_node(type, value: data.join)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def pack_one(type)
|
|
136
|
+
pack = true
|
|
137
|
+
pack_while(type) { pack.tap { pack = !pack } }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def valid_escape?(offset: 0)
|
|
141
|
+
check = -> { peek == REVERSE_SOLIDUS && peek1 != LINE_FEED }
|
|
142
|
+
if offset.positive?
|
|
143
|
+
isolated do
|
|
144
|
+
offset.times { advance }
|
|
145
|
+
check.call
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
check.call
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def ident_sequence_start?
|
|
153
|
+
case peek
|
|
154
|
+
when HYPHEN_MINUS
|
|
155
|
+
(peek1.ident_start? || peek1 == HYPHEN_MINUS) || valid_escape?(offset: 1)
|
|
156
|
+
when REVERSE_SOLIDUS
|
|
157
|
+
valid_escape?
|
|
158
|
+
else
|
|
159
|
+
peek.ident_start?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Caps
|
|
4
|
+
class Tokenizer
|
|
5
|
+
class Location
|
|
6
|
+
attr_reader :start
|
|
7
|
+
|
|
8
|
+
def initialize(parser)
|
|
9
|
+
@parser = parser
|
|
10
|
+
@start = parser.pos
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def push_node(type, **opts)
|
|
14
|
+
@parser.push_node(type, **opts.merge({ position: finish }))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def finish
|
|
18
|
+
{
|
|
19
|
+
start: @start,
|
|
20
|
+
end: @parser.pos
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|