luoma 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
- checksums.yaml.gz.sig +2 -0
- data/.rdoc_options +16 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +68 -0
- data/Rakefile +69 -0
- data/Steepfile +41 -0
- data/certs/jgrp.pem +27 -0
- data/docs/configuration.md +180 -0
- data/docs/custom_filters.md +3 -0
- data/docs/custom_tags.md +3 -0
- data/docs/expressions.md +3 -0
- data/docs/extension_types.md +41 -0
- data/docs/filter_reference.md +1702 -0
- data/docs/index.md +73 -0
- data/docs/luoma_for_template_authors.md +3 -0
- data/docs/markdown.md +111 -0
- data/docs/predicate_reference.md +3 -0
- data/docs/static_analysis.md +3 -0
- data/docs/tag_reference.md +352 -0
- data/docs/template_loaders.md +177 -0
- data/docs/undefined_variables.md +3 -0
- data/docs-requirements.txt +11 -0
- data/docs_/cycle.md +17 -0
- data/docs_/font_rendering_example.md +157 -0
- data/docs_/header_block_example.md +51 -0
- data/docs_/increment_and_decrement.md +49 -0
- data/docs_/logo_style_example.md +40 -0
- data/docs_/migration.md +11 -0
- data/docs_/notes.md +19 -0
- data/lib/luoma/cache.rb +80 -0
- data/lib/luoma/chain_hash.rb +54 -0
- data/lib/luoma/context.rb +227 -0
- data/lib/luoma/drop.rb +84 -0
- data/lib/luoma/drops/block.rb +33 -0
- data/lib/luoma/drops/expression.rb +29 -0
- data/lib/luoma/drops/html_safe.rb +36 -0
- data/lib/luoma/drops/range.rb +69 -0
- data/lib/luoma/drops/undefined.rb +119 -0
- data/lib/luoma/environment.rb +479 -0
- data/lib/luoma/errors.rb +79 -0
- data/lib/luoma/escape.rb +35 -0
- data/lib/luoma/expression.rb +1271 -0
- data/lib/luoma/filter.rb +97 -0
- data/lib/luoma/filters/array.rb +372 -0
- data/lib/luoma/filters/date.rb +19 -0
- data/lib/luoma/filters/default.rb +16 -0
- data/lib/luoma/filters/json.rb +14 -0
- data/lib/luoma/filters/math.rb +84 -0
- data/lib/luoma/filters/size.rb +13 -0
- data/lib/luoma/filters/slice.rb +52 -0
- data/lib/luoma/filters/sort.rb +113 -0
- data/lib/luoma/filters/string.rb +186 -0
- data/lib/luoma/fnv.rb +15 -0
- data/lib/luoma/lexer.rb +106 -0
- data/lib/luoma/lexer_legacy.rb +396 -0
- data/lib/luoma/lexer_unified.rb +279 -0
- data/lib/luoma/loader.rb +50 -0
- data/lib/luoma/loaders/choice_loader.rb +20 -0
- data/lib/luoma/loaders/file_system_loader.rb +75 -0
- data/lib/luoma/loaders/mixins.rb +52 -0
- data/lib/luoma/markup.rb +109 -0
- data/lib/luoma/parser.rb +252 -0
- data/lib/luoma/parser_unified.rb +979 -0
- data/lib/luoma/predicates/blank.rb +19 -0
- data/lib/luoma/predicates/defined.rb +10 -0
- data/lib/luoma/predicates/empty.rb +15 -0
- data/lib/luoma/predicates/type.rb +35 -0
- data/lib/luoma/static_analysis.rb +427 -0
- data/lib/luoma/tags/assign.rb +63 -0
- data/lib/luoma/tags/break.rb +23 -0
- data/lib/luoma/tags/capture.rb +43 -0
- data/lib/luoma/tags/case.rb +137 -0
- data/lib/luoma/tags/comment.rb +17 -0
- data/lib/luoma/tags/continue.rb +23 -0
- data/lib/luoma/tags/define.rb +42 -0
- data/lib/luoma/tags/else.rb +30 -0
- data/lib/luoma/tags/for.rb +108 -0
- data/lib/luoma/tags/if.rb +109 -0
- data/lib/luoma/tags/import.rb +91 -0
- data/lib/luoma/tags/include.rb +77 -0
- data/lib/luoma/tags/output.rb +20 -0
- data/lib/luoma/tags/raw.rb +26 -0
- data/lib/luoma/tags/render.rb +92 -0
- data/lib/luoma/tags/with.rb +55 -0
- data/lib/luoma/template.rb +178 -0
- data/lib/luoma/token.rb +84 -0
- data/lib/luoma/version.rb +5 -0
- data/lib/luoma.rb +75 -0
- data/sig/luoma/cache.rbs +44 -0
- data/sig/luoma/chain_hash.rbs +26 -0
- data/sig/luoma/context.rbs +115 -0
- data/sig/luoma/drop.rbs +44 -0
- data/sig/luoma/drops/block.rbs +10 -0
- data/sig/luoma/drops/expression.rbs +10 -0
- data/sig/luoma/drops/html_safe.rbs +14 -0
- data/sig/luoma/drops/range.rbs +15 -0
- data/sig/luoma/drops/undefined.rbs +27 -0
- data/sig/luoma/environment.rbs +212 -0
- data/sig/luoma/errors.rbs +67 -0
- data/sig/luoma/escape.rbs +15 -0
- data/sig/luoma/expression.rbs +514 -0
- data/sig/luoma/filter.rbs +66 -0
- data/sig/luoma/filters/array.rbs +74 -0
- data/sig/luoma/filters/date.rbs +9 -0
- data/sig/luoma/filters/default.rbs +8 -0
- data/sig/luoma/filters/json.rbs +7 -0
- data/sig/luoma/filters/math.rbs +37 -0
- data/sig/luoma/filters/size.rbs +6 -0
- data/sig/luoma/filters/slice.rbs +10 -0
- data/sig/luoma/filters/sort.rbs +17 -0
- data/sig/luoma/filters/string.rbs +103 -0
- data/sig/luoma/fnv.rbs +3 -0
- data/sig/luoma/lexer.rbs +42 -0
- data/sig/luoma/lexer_legacy.rbs +81 -0
- data/sig/luoma/lexer_unified.rbs +49 -0
- data/sig/luoma/loader.rbs +40 -0
- data/sig/luoma/loaders/choice_loader.rbs +9 -0
- data/sig/luoma/loaders/file_system_loader.rbs +19 -0
- data/sig/luoma/loaders/mixins.rbs +16 -0
- data/sig/luoma/markup.rbs +68 -0
- data/sig/luoma/parser.rbs +105 -0
- data/sig/luoma/parser_unified.rbs +150 -0
- data/sig/luoma/predicates/blank.rbs +6 -0
- data/sig/luoma/predicates/defined.rbs +6 -0
- data/sig/luoma/predicates/empty.rbs +6 -0
- data/sig/luoma/predicates/type.rbs +21 -0
- data/sig/luoma/static_analysis.rbs +141 -0
- data/sig/luoma/tags/assign.rbs +12 -0
- data/sig/luoma/tags/break.rbs +11 -0
- data/sig/luoma/tags/capture.rbs +14 -0
- data/sig/luoma/tags/case.rbs +36 -0
- data/sig/luoma/tags/comment.rbs +11 -0
- data/sig/luoma/tags/continue.rbs +11 -0
- data/sig/luoma/tags/define.rbs +16 -0
- data/sig/luoma/tags/else.rbs +17 -0
- data/sig/luoma/tags/for.rbs +26 -0
- data/sig/luoma/tags/if.rbs +45 -0
- data/sig/luoma/tags/import.rbs +13 -0
- data/sig/luoma/tags/include.rbs +13 -0
- data/sig/luoma/tags/output.rbs +13 -0
- data/sig/luoma/tags/raw.rbs +11 -0
- data/sig/luoma/tags/render.rbs +13 -0
- data/sig/luoma/tags/with.rbs +15 -0
- data/sig/luoma/template.rbs +117 -0
- data/sig/luoma/token.rbs +81 -0
- data/sig/luoma.rbs +11 -0
- data/zensical.toml +363 -0
- data.tar.gz.sig +0 -0
- metadata +233 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lexer"
|
|
4
|
+
|
|
5
|
+
module Luoma
|
|
6
|
+
# A single-pass template tokenizer that matches Shopify/liquid v5.12.0 strict
|
|
7
|
+
# mode syntax.
|
|
8
|
+
#
|
|
9
|
+
# We use lookahead and byte offset limits to achieve behavior equivalent to
|
|
10
|
+
# Shopify's two-pass tokenizer/parser.
|
|
11
|
+
class LegacyLexer < BaseLexer
|
|
12
|
+
RE_MARKUP = /\{[%{]/
|
|
13
|
+
RE_OUT_END = /-?(\}\}?|%\}(?!\}))/
|
|
14
|
+
RE_TAG_END = /-?%\}/
|
|
15
|
+
RE_TRIVIA = /[ \n\r\t\f]+/
|
|
16
|
+
RE_LINE_TRIVIA = /[ \t\f\r]+/
|
|
17
|
+
RE_TAG_NAME = /#|[a-zA-Z0-9_]+/
|
|
18
|
+
RE_PUNCTUATION = /[.!=<>]{1,2}|[?\[\]|:,()]/
|
|
19
|
+
RE_IDENT = /[a-zA-Z_][a-zA-Z0-9_-]*\??/
|
|
20
|
+
RE_FLOAT = /-?\d+\.\d+/
|
|
21
|
+
RE_INT = /-?\d+/
|
|
22
|
+
RE_COMMENT_SEGMENT = /\{%-?\s*(comment|raw|endcomment|endraw).*?-?%\}/m
|
|
23
|
+
RE_LINE_COMMENT_SEGMENT = /\n\s*(comment|endcomment).*/
|
|
24
|
+
RE_END_DOC = /\{%-?\s*enddoc\s*-?%\}/
|
|
25
|
+
RE_END_RAW = /\{%-?\s*endraw\s*-?%\}/
|
|
26
|
+
|
|
27
|
+
# Keywords and symbols that get their own token kind.
|
|
28
|
+
TOKEN_MAP = {
|
|
29
|
+
"true" => :token_true,
|
|
30
|
+
"false" => :token_false,
|
|
31
|
+
"nil" => :token_nil,
|
|
32
|
+
"null" => :token_nil,
|
|
33
|
+
"and" => :token_and,
|
|
34
|
+
"or" => :token_or,
|
|
35
|
+
"contains" => :token_contains,
|
|
36
|
+
"in" => :token_in,
|
|
37
|
+
"blank" => :token_blank,
|
|
38
|
+
"empty" => :token_empty,
|
|
39
|
+
"[" => :token_lbracket,
|
|
40
|
+
"]" => :token_rbracket,
|
|
41
|
+
"|" => :token_pipe,
|
|
42
|
+
"." => :token_dot,
|
|
43
|
+
".." => :token_double_dot,
|
|
44
|
+
"," => :token_comma,
|
|
45
|
+
":" => :token_colon,
|
|
46
|
+
"(" => :token_lparen,
|
|
47
|
+
")" => :token_rparen,
|
|
48
|
+
"=" => :token_assign,
|
|
49
|
+
"<" => :token_lt,
|
|
50
|
+
"<=" => :token_le,
|
|
51
|
+
"<>" => :token_ne,
|
|
52
|
+
">" => :token_gt,
|
|
53
|
+
">=" => :token_ge,
|
|
54
|
+
"==" => :token_eq,
|
|
55
|
+
"!=" => :token_ne,
|
|
56
|
+
"#" => :token_hash
|
|
57
|
+
}.freeze #: Hash[String, t_token_kind]
|
|
58
|
+
|
|
59
|
+
#: () -> Symbol?
|
|
60
|
+
def scan_markup
|
|
61
|
+
limit = nil #: Integer?
|
|
62
|
+
|
|
63
|
+
loop do
|
|
64
|
+
case @scanner.scan(RE_MARKUP)
|
|
65
|
+
when "{{"
|
|
66
|
+
# Output statements can be closed by `}}`, `}` or `%}`.
|
|
67
|
+
# Markup delimiters are greedy and not string literal aware.
|
|
68
|
+
limit = index(RE_OUT_END)
|
|
69
|
+
|
|
70
|
+
if limit.nil?
|
|
71
|
+
# Not markup and no more '}'. Emit text to end of string.
|
|
72
|
+
@scanner.terminate
|
|
73
|
+
emit(:token_text)
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
emit(:token_out_start)
|
|
78
|
+
accept_whitespace_control?
|
|
79
|
+
accept_expression(limit)
|
|
80
|
+
skip?(RE_TRIVIA)
|
|
81
|
+
accept_whitespace_control?
|
|
82
|
+
@scanner.scan(RE_OUT_END)
|
|
83
|
+
emit(:token_out_end)
|
|
84
|
+
when "{%"
|
|
85
|
+
# Tags must be closed by `%}`.
|
|
86
|
+
# Markup delimiters are greedy and not string literal aware.
|
|
87
|
+
limit = index(RE_TAG_END)
|
|
88
|
+
|
|
89
|
+
if limit.nil?
|
|
90
|
+
# No more `%}`, but there could be `{{` and `}}`
|
|
91
|
+
if scan_until?(RE_MARKUP)
|
|
92
|
+
emit(:token_text)
|
|
93
|
+
else
|
|
94
|
+
# No more markup. Emit text to end of string.
|
|
95
|
+
@scanner.terminate
|
|
96
|
+
emit(:token_text) if @start < @scanner.pos
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
else
|
|
100
|
+
emit(:token_tag_start)
|
|
101
|
+
accept_whitespace_control?
|
|
102
|
+
skip?(RE_TRIVIA)
|
|
103
|
+
accept_tag(limit)
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
# No more `%}`, but there could be `{{` and `}}`
|
|
107
|
+
if scan_until?(RE_MARKUP)
|
|
108
|
+
emit(:token_text)
|
|
109
|
+
else
|
|
110
|
+
# No more markup. Emit text to end of string.
|
|
111
|
+
@scanner.terminate
|
|
112
|
+
emit(:token_text) if @start < @scanner.pos
|
|
113
|
+
return nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (Integer) -> void
|
|
120
|
+
def accept_tag(limit)
|
|
121
|
+
tag_name = @scanner.scan(RE_TAG_NAME)
|
|
122
|
+
|
|
123
|
+
emit(:token_tag_name) unless tag_name.nil?
|
|
124
|
+
|
|
125
|
+
case tag_name
|
|
126
|
+
when "#"
|
|
127
|
+
accept_inline_comment(limit)
|
|
128
|
+
when "comment"
|
|
129
|
+
accept_block_comment(limit)
|
|
130
|
+
when "doc"
|
|
131
|
+
accept_doc_comment(limit)
|
|
132
|
+
when "raw"
|
|
133
|
+
accept_raw_tag(limit)
|
|
134
|
+
when "liquid"
|
|
135
|
+
accept_line_statements(limit)
|
|
136
|
+
else
|
|
137
|
+
accept_expression(limit)
|
|
138
|
+
skip?(RE_TRIVIA)
|
|
139
|
+
accept_whitespace_control?
|
|
140
|
+
@scanner.scan(RE_TAG_END)
|
|
141
|
+
emit(:token_tag_end)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: (Integer, ?Regexp) -> void
|
|
146
|
+
def accept_expression(limit, trivia = RE_TRIVIA)
|
|
147
|
+
loop do
|
|
148
|
+
skip?(trivia)
|
|
149
|
+
|
|
150
|
+
# Trivia can put us past the limit
|
|
151
|
+
break if @scanner.pos >= limit
|
|
152
|
+
|
|
153
|
+
# We assume punctuation does not include markup delimiter characters,
|
|
154
|
+
# and can therefore not exceed `limit`.
|
|
155
|
+
if (match = @scanner.scan(RE_PUNCTUATION))
|
|
156
|
+
emit(TOKEN_MAP[match] || :token_unknown)
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# We assume identifiers do not allow markup delimiter characters,
|
|
161
|
+
# and can therefore not exceed `limit`.
|
|
162
|
+
if (match = @scanner.scan(RE_IDENT))
|
|
163
|
+
emit(TOKEN_MAP[match] || :token_ident)
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# RE_FLOAT must come before RE_INT
|
|
168
|
+
if @scanner.scan(RE_FLOAT)
|
|
169
|
+
emit(:token_float)
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if @scanner.scan(RE_INT)
|
|
174
|
+
emit(:token_int)
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
next_byte = @scanner.peek_byte # steep:ignore
|
|
179
|
+
|
|
180
|
+
if next_byte == 39 || next_byte == 34
|
|
181
|
+
accept_string_literal(limit)
|
|
182
|
+
else
|
|
183
|
+
@scanner.pos += 1
|
|
184
|
+
emit(:token_unknown)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
#: (Integer) -> void
|
|
190
|
+
def accept_inline_comment(limit)
|
|
191
|
+
@scanner.pos = limit
|
|
192
|
+
emit(:token_comment)
|
|
193
|
+
accept_whitespace_control?
|
|
194
|
+
@scanner.scan(RE_TAG_END)
|
|
195
|
+
emit(:token_tag_end)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
#: (Integer) -> void
|
|
199
|
+
def accept_block_comment(limit)
|
|
200
|
+
skip?(RE_TRIVIA)
|
|
201
|
+
# Ignore any "expression".
|
|
202
|
+
@scanner.pos = limit
|
|
203
|
+
accept_whitespace_control?
|
|
204
|
+
@scanner.scan(RE_TAG_END)
|
|
205
|
+
emit(:token_tag_end)
|
|
206
|
+
|
|
207
|
+
comment_depth = 1
|
|
208
|
+
raw_depth = 0
|
|
209
|
+
|
|
210
|
+
loop do
|
|
211
|
+
match = skip_until?(RE_COMMENT_SEGMENT)
|
|
212
|
+
|
|
213
|
+
unless match
|
|
214
|
+
emit(:token_unknown)
|
|
215
|
+
break
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
case @scanner[1]
|
|
219
|
+
when "comment"
|
|
220
|
+
comment_depth += 1
|
|
221
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
222
|
+
when "raw"
|
|
223
|
+
raw_depth += 1
|
|
224
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
225
|
+
when "endraw"
|
|
226
|
+
raw_depth -= 1 if raw_depth.positive?
|
|
227
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
228
|
+
when "endcomment"
|
|
229
|
+
if raw_depth.positive?
|
|
230
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
comment_depth -= 1
|
|
235
|
+
|
|
236
|
+
if comment_depth.positive?
|
|
237
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
238
|
+
next
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
emit(:token_comment)
|
|
242
|
+
break
|
|
243
|
+
else
|
|
244
|
+
raise "unreachable"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
#: (Integer) -> void
|
|
250
|
+
def accept_doc_comment(limit)
|
|
251
|
+
# Let the parser handle unexpected expression tokens.
|
|
252
|
+
accept_expression(limit)
|
|
253
|
+
skip?(RE_TRIVIA)
|
|
254
|
+
accept_whitespace_control?
|
|
255
|
+
@scanner.scan(RE_TAG_END)
|
|
256
|
+
emit(:token_tag_end)
|
|
257
|
+
emit(:token_comment) if scan_until?(RE_END_DOC)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
#: (Integer) -> void
|
|
261
|
+
def accept_raw_tag(limit)
|
|
262
|
+
# Let the parser handle unexpected expression tokens.
|
|
263
|
+
accept_expression(limit)
|
|
264
|
+
skip?(RE_TRIVIA)
|
|
265
|
+
accept_whitespace_control?
|
|
266
|
+
@scanner.scan(RE_TAG_END)
|
|
267
|
+
emit(:token_tag_end)
|
|
268
|
+
emit(:token_text) if scan_until?(RE_END_RAW)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
#: (Integer) -> void
|
|
272
|
+
def accept_line_statements(limit)
|
|
273
|
+
while @scanner.pos < limit
|
|
274
|
+
skip?(RE_TRIVIA)
|
|
275
|
+
line_limit = @scanner.string.byteindex("\n", @scanner.pos) || limit
|
|
276
|
+
line_limit = limit if line_limit > limit
|
|
277
|
+
|
|
278
|
+
emit(:token_tag_start)
|
|
279
|
+
|
|
280
|
+
case @scanner.scan(RE_TAG_NAME)
|
|
281
|
+
when "#"
|
|
282
|
+
emit(:token_tag_name)
|
|
283
|
+
@scanner.pos = line_limit
|
|
284
|
+
emit(:token_comment)
|
|
285
|
+
emit(:token_tag_end)
|
|
286
|
+
when "comment"
|
|
287
|
+
emit(:token_tag_name)
|
|
288
|
+
emit(:token_tag_end)
|
|
289
|
+
accept_line_block_comment(limit)
|
|
290
|
+
when "doc"
|
|
291
|
+
emit(:token_tag_name)
|
|
292
|
+
accept_line_doc_comment(limit)
|
|
293
|
+
when "raw"
|
|
294
|
+
emit(:token_tag_name)
|
|
295
|
+
accept_line_raw_tag(limit)
|
|
296
|
+
when "liquid"
|
|
297
|
+
emit(:token_tag_name)
|
|
298
|
+
accept_line_statements(line_limit)
|
|
299
|
+
when nil
|
|
300
|
+
# Remove empty :token_tag_start
|
|
301
|
+
@tokens.pop
|
|
302
|
+
else
|
|
303
|
+
emit(:token_tag_name)
|
|
304
|
+
accept_expression(line_limit, RE_LINE_TRIVIA)
|
|
305
|
+
emit(:token_tag_end)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
skip?(RE_TRIVIA)
|
|
310
|
+
accept_whitespace_control?
|
|
311
|
+
@scanner.scan(RE_TAG_END)
|
|
312
|
+
emit(:token_tag_end)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
#: (Integer) -> void
|
|
316
|
+
def accept_line_block_comment(limit)
|
|
317
|
+
comment_depth = 1
|
|
318
|
+
|
|
319
|
+
while @scanner.pos < limit
|
|
320
|
+
unless skip_until?(RE_LINE_COMMENT_SEGMENT)
|
|
321
|
+
emit(:token_unknown)
|
|
322
|
+
break
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
case @scanner[1]
|
|
326
|
+
when "comment"
|
|
327
|
+
comment_depth += 1
|
|
328
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
329
|
+
when "endcomment"
|
|
330
|
+
comment_depth -= 1
|
|
331
|
+
if comment_depth.positive?
|
|
332
|
+
@scanner.pos += @scanner.matched_size || raise
|
|
333
|
+
next
|
|
334
|
+
else
|
|
335
|
+
emit(:token_comment)
|
|
336
|
+
break
|
|
337
|
+
end
|
|
338
|
+
else
|
|
339
|
+
raise "unreachable"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
#: (Integer) -> void
|
|
345
|
+
def accept_line_doc_comment(limit)
|
|
346
|
+
# Shopify/liquid always raises a syntax error for `doc` in `{% liquid %}`.
|
|
347
|
+
@scanner.pos = limit
|
|
348
|
+
emit(:token_unknown)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
#: (Integer) -> void
|
|
352
|
+
def accept_line_raw_tag(limit)
|
|
353
|
+
# Shopify/liquid always raises a syntax error for `raw` in `{% liquid %}`.
|
|
354
|
+
@scanner.pos = limit
|
|
355
|
+
emit(:token_unknown)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
#: (Integer) -> void
|
|
359
|
+
def accept_string_literal(limit)
|
|
360
|
+
quote = @scanner.get_byte || raise
|
|
361
|
+
byte = quote.ord
|
|
362
|
+
double = byte == 34
|
|
363
|
+
kind = double ? :token_double_quote : :token_single_quote #: t_token_kind
|
|
364
|
+
emit(kind)
|
|
365
|
+
|
|
366
|
+
if @scanner.peek_byte == byte # steep:ignore
|
|
367
|
+
# Empty string
|
|
368
|
+
@scanner.pos += 1
|
|
369
|
+
emit(kind)
|
|
370
|
+
return
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Jump to the next quote or limit, whichever is closer.
|
|
374
|
+
index_ = @scanner.string.byteindex(quote, @scanner.pos) || limit
|
|
375
|
+
index_ = limit if index_ > limit
|
|
376
|
+
@scanner.pos = index_
|
|
377
|
+
emit(double ? :token_double_quoted : :token_single_quoted)
|
|
378
|
+
|
|
379
|
+
if @scanner.peek_byte == byte # steep:ignore
|
|
380
|
+
@scanner.pos += 1
|
|
381
|
+
emit(kind)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
#: () -> bool
|
|
386
|
+
def accept_whitespace_control?
|
|
387
|
+
if @scanner.peek_byte == 45 # steep:ignore
|
|
388
|
+
@scanner.pos += 1
|
|
389
|
+
emit(:token_wc)
|
|
390
|
+
true
|
|
391
|
+
else
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lexer"
|
|
4
|
+
|
|
5
|
+
module Luoma
|
|
6
|
+
# A single-pass template tokenizer for the Unified Expression Language with
|
|
7
|
+
# new style comments and no line statements.
|
|
8
|
+
#
|
|
9
|
+
# https://jg-rp.github.io/template-expression-spec/
|
|
10
|
+
class UnifiedLexer < BaseLexer
|
|
11
|
+
RE_FLOAT = /((?:\d+\.\d+(?:[eE][+-]?\d+)?)|(\d+[eE]-\d+))/
|
|
12
|
+
RE_INT = /\d+(?:[eE]\+?\d+)?/
|
|
13
|
+
RE_MARKUP_START = /\{[%{#]/
|
|
14
|
+
RE_OUTPUT_END = /\}\}/
|
|
15
|
+
RE_PUNCTUATION = /!=|>=|<=|==|=>|->|\.{1,3}|[?\[\]|:,()*\/<>=]|([+-](?![}%#]\}))|(%(?!\}))/
|
|
16
|
+
RE_RAW_END = /\{%[+~-]?\s*endraw\s*[+~-]?%\}/
|
|
17
|
+
RE_TAG_END = /%\}/
|
|
18
|
+
RE_TAG_NAME = /[a-z][a-z_0-9]*/
|
|
19
|
+
RE_TRIVIA = /[ \n\r\t]+/
|
|
20
|
+
RE_WHITESPACE_CONTROL = /[+\-~]/
|
|
21
|
+
RE_WORD = /[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*/
|
|
22
|
+
|
|
23
|
+
RE_HASH_COUNT = {
|
|
24
|
+
1 => /[+\-~]?\#\}/,
|
|
25
|
+
2 => /[+\-~]?\#{2}\}/,
|
|
26
|
+
3 => /[+\-~]?\#{3}\}/,
|
|
27
|
+
4 => /[+\-~]?\#{4}\}/,
|
|
28
|
+
5 => /[+\-~]?\#{5}\}/,
|
|
29
|
+
6 => /[+\-~]?\#{6}\}/,
|
|
30
|
+
7 => /[+\-~]?\#{7}\}/
|
|
31
|
+
}.freeze #: Hash[Integer, Regexp]
|
|
32
|
+
|
|
33
|
+
# Keywords and symbols that get their own token kind.
|
|
34
|
+
TOKEN_MAP = {
|
|
35
|
+
"true" => :token_true,
|
|
36
|
+
"false" => :token_false,
|
|
37
|
+
"nil" => :token_nil,
|
|
38
|
+
"null" => :token_nil,
|
|
39
|
+
"and" => :token_and,
|
|
40
|
+
"or" => :token_or,
|
|
41
|
+
"orElse" => :token_or_else,
|
|
42
|
+
"not" => :token_not,
|
|
43
|
+
"contains" => :token_contains,
|
|
44
|
+
"in" => :token_in,
|
|
45
|
+
"if" => :token_if,
|
|
46
|
+
"else" => :token_else,
|
|
47
|
+
"?" => :token_question,
|
|
48
|
+
"[" => :token_lbracket,
|
|
49
|
+
"]" => :token_rbracket,
|
|
50
|
+
"|" => :token_pipe,
|
|
51
|
+
"." => :token_dot,
|
|
52
|
+
".." => :token_double_dot,
|
|
53
|
+
"..." => :token_triple_dot,
|
|
54
|
+
"," => :token_comma,
|
|
55
|
+
":" => :token_colon,
|
|
56
|
+
"(" => :token_lparen,
|
|
57
|
+
")" => :token_rparen,
|
|
58
|
+
"=" => :token_assign,
|
|
59
|
+
"<" => :token_lt,
|
|
60
|
+
"<=" => :token_le,
|
|
61
|
+
"<>" => :token_ne,
|
|
62
|
+
">" => :token_gt,
|
|
63
|
+
">=" => :token_ge,
|
|
64
|
+
"==" => :token_eq,
|
|
65
|
+
"!=" => :token_ne,
|
|
66
|
+
"=>" => :token_arrow,
|
|
67
|
+
"->" => :token_arrow,
|
|
68
|
+
"+" => :token_add,
|
|
69
|
+
"-" => :token_sub,
|
|
70
|
+
"%" => :token_mod,
|
|
71
|
+
"*" => :token_mul,
|
|
72
|
+
"/" => :token_div
|
|
73
|
+
}.freeze #: Hash[String, t_token_kind]
|
|
74
|
+
|
|
75
|
+
#: () -> Symbol?
|
|
76
|
+
def scan_markup
|
|
77
|
+
loop do
|
|
78
|
+
case @scanner.scan(RE_MARKUP_START)
|
|
79
|
+
when "{{"
|
|
80
|
+
emit(:token_out_start)
|
|
81
|
+
accept_whitespace_control?
|
|
82
|
+
accept_expression
|
|
83
|
+
skip?(RE_TRIVIA)
|
|
84
|
+
accept_whitespace_control?
|
|
85
|
+
emit(:token_out_end) if @scanner.scan(RE_OUTPUT_END)
|
|
86
|
+
when "{%"
|
|
87
|
+
emit(:token_tag_start)
|
|
88
|
+
accept_whitespace_control?
|
|
89
|
+
skip?(RE_TRIVIA)
|
|
90
|
+
accept_tag
|
|
91
|
+
when "{#"
|
|
92
|
+
accept_comment
|
|
93
|
+
else
|
|
94
|
+
if scan_until?(RE_MARKUP_START)
|
|
95
|
+
emit(:token_text)
|
|
96
|
+
else
|
|
97
|
+
# No more markup. Emit text to end of string.
|
|
98
|
+
@scanner.terminate
|
|
99
|
+
emit(:token_text) if @start < @scanner.pos
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: () -> void
|
|
107
|
+
def accept_tag
|
|
108
|
+
tag_name = @scanner.scan(RE_TAG_NAME)
|
|
109
|
+
|
|
110
|
+
case tag_name
|
|
111
|
+
when "raw"
|
|
112
|
+
emit(:token_tag_name)
|
|
113
|
+
skip?(RE_TRIVIA)
|
|
114
|
+
accept_whitespace_control?
|
|
115
|
+
emit(:token_tag_end) if @scanner.scan(RE_TAG_END)
|
|
116
|
+
scan_until?(RE_RAW_END)
|
|
117
|
+
emit(:token_text) if @start < @scanner.pos
|
|
118
|
+
when nil
|
|
119
|
+
# Missing or malformed tag name
|
|
120
|
+
accept_expression
|
|
121
|
+
skip?(RE_TRIVIA)
|
|
122
|
+
accept_whitespace_control?
|
|
123
|
+
emit(:token_tag_end) if @scanner.scan(RE_TAG_END)
|
|
124
|
+
else
|
|
125
|
+
emit(:token_tag_name)
|
|
126
|
+
accept_expression
|
|
127
|
+
skip?(RE_TRIVIA)
|
|
128
|
+
accept_whitespace_control?
|
|
129
|
+
emit(:token_tag_end) if @scanner.scan(RE_TAG_END)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: () -> void
|
|
134
|
+
def accept_expression
|
|
135
|
+
loop do
|
|
136
|
+
skip?(RE_TRIVIA)
|
|
137
|
+
|
|
138
|
+
if (match = @scanner.scan(RE_PUNCTUATION))
|
|
139
|
+
emit(TOKEN_MAP[match] || :token_unknown)
|
|
140
|
+
elsif (match = @scanner.scan(RE_WORD))
|
|
141
|
+
emit(TOKEN_MAP[match] || :token_ident)
|
|
142
|
+
elsif @scanner.scan(RE_FLOAT)
|
|
143
|
+
emit(:token_float)
|
|
144
|
+
elsif @scanner.scan(RE_INT)
|
|
145
|
+
emit(:token_int)
|
|
146
|
+
else
|
|
147
|
+
case @scanner.peek_byte # steep:ignore
|
|
148
|
+
when 39, 34 # ' or "
|
|
149
|
+
# String literals get their own state because we allow markup
|
|
150
|
+
# delimiters inside quotes.
|
|
151
|
+
accept_string
|
|
152
|
+
when 123 # {
|
|
153
|
+
# Object literals require their own state so we can tell the
|
|
154
|
+
# difference between a closing brace and the start of a closing
|
|
155
|
+
# output delimiter.
|
|
156
|
+
accept_object
|
|
157
|
+
else
|
|
158
|
+
# Non-expression byte or end of input.
|
|
159
|
+
break
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
#: () -> void
|
|
166
|
+
def accept_comment
|
|
167
|
+
start_of_delim = @start
|
|
168
|
+
@scanner.pos += 1 while @scanner.peek_byte == 35 # steep:ignore
|
|
169
|
+
hash_count = (@source.byteslice((@start + 1)...@scanner.pos) || raise).size
|
|
170
|
+
re = RE_HASH_COUNT[hash_count]
|
|
171
|
+
|
|
172
|
+
emit(:token_comment_start)
|
|
173
|
+
wc = accept_whitespace_control?
|
|
174
|
+
|
|
175
|
+
if re && scan_until?(re)
|
|
176
|
+
emit(:token_comment)
|
|
177
|
+
accept_whitespace_control?
|
|
178
|
+
@scanner.scan(re)
|
|
179
|
+
emit(:token_comment_end)
|
|
180
|
+
else
|
|
181
|
+
# No closing delimiter. Not a comment.
|
|
182
|
+
@tokens.pop
|
|
183
|
+
@tokens.pop if wc
|
|
184
|
+
@start = start_of_delim
|
|
185
|
+
emit(:token_text)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
#: () -> void
|
|
190
|
+
def accept_string
|
|
191
|
+
# `get_byte` returns a string, `peek_byte` returns an integer :(
|
|
192
|
+
quote = @scanner.get_byte || raise
|
|
193
|
+
byte = quote.ord
|
|
194
|
+
double = quote == '"'
|
|
195
|
+
|
|
196
|
+
quote_kind = double ? :token_double_quote : :token_single_quote #: t_token_kind
|
|
197
|
+
unescaped_kind = double ? :token_double_quoted : :token_single_quoted #: t_token_kind
|
|
198
|
+
escaped_kind = double ? :token_double_escaped : :token_single_escaped #: t_token_kind
|
|
199
|
+
current_kind = unescaped_kind #: t_token_kind
|
|
200
|
+
|
|
201
|
+
emit(quote_kind)
|
|
202
|
+
|
|
203
|
+
if @scanner.peek_byte == byte # steep:ignore
|
|
204
|
+
@scanner.pos += 1
|
|
205
|
+
emit(quote_kind)
|
|
206
|
+
return
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
loop do
|
|
210
|
+
case @scanner.get_byte
|
|
211
|
+
when quote
|
|
212
|
+
@scanner.pos -= 1
|
|
213
|
+
emit(current_kind) if @start < @scanner.pos
|
|
214
|
+
@scanner.pos += 1
|
|
215
|
+
emit(quote_kind)
|
|
216
|
+
break
|
|
217
|
+
when "\\"
|
|
218
|
+
@scanner.pos -= 1
|
|
219
|
+
# Emit unescaped segment, if any.
|
|
220
|
+
emit(current_kind) if current_kind == unescaped_kind && @start < @scanner.pos
|
|
221
|
+
@scanner.pos += 2
|
|
222
|
+
current_kind = escaped_kind
|
|
223
|
+
when "$"
|
|
224
|
+
next unless @scanner.peek_byte == 123 # steep:ignore
|
|
225
|
+
|
|
226
|
+
@scanner.pos -= 1
|
|
227
|
+
emit(current_kind) if @start < @scanner.pos
|
|
228
|
+
|
|
229
|
+
current_kind = unescaped_kind
|
|
230
|
+
@scanner.pos += 2
|
|
231
|
+
emit(:token_interpolation_start)
|
|
232
|
+
|
|
233
|
+
accept_expression
|
|
234
|
+
|
|
235
|
+
if @scanner.peek_byte == 125 # steep:ignore
|
|
236
|
+
@scanner.pos += 1
|
|
237
|
+
emit(:token_interpolation_end)
|
|
238
|
+
else
|
|
239
|
+
# unclosed interpolation. Let the parser handle it.
|
|
240
|
+
break
|
|
241
|
+
end
|
|
242
|
+
when nil
|
|
243
|
+
# Unclosed string literal. Let the parser handle it.
|
|
244
|
+
break
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
#: () -> void
|
|
250
|
+
def accept_object
|
|
251
|
+
@scanner.pos += 1
|
|
252
|
+
emit(:token_lbrace)
|
|
253
|
+
|
|
254
|
+
loop do
|
|
255
|
+
case @scanner.peek_byte # steep:ignore
|
|
256
|
+
when 125 # }
|
|
257
|
+
@scanner.pos += 1
|
|
258
|
+
emit(:token_rbrace)
|
|
259
|
+
break
|
|
260
|
+
when nil
|
|
261
|
+
# Unclosed object literal.
|
|
262
|
+
break
|
|
263
|
+
else
|
|
264
|
+
accept_expression
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
#: () -> bool
|
|
270
|
+
def accept_whitespace_control?
|
|
271
|
+
if @scanner.scan(RE_WHITESPACE_CONTROL)
|
|
272
|
+
emit(:token_wc)
|
|
273
|
+
true
|
|
274
|
+
else
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|