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.
Files changed (152) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +2 -0
  3. data/.rdoc_options +16 -0
  4. data/CHANGELOG.md +5 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +68 -0
  7. data/Rakefile +69 -0
  8. data/Steepfile +41 -0
  9. data/certs/jgrp.pem +27 -0
  10. data/docs/configuration.md +180 -0
  11. data/docs/custom_filters.md +3 -0
  12. data/docs/custom_tags.md +3 -0
  13. data/docs/expressions.md +3 -0
  14. data/docs/extension_types.md +41 -0
  15. data/docs/filter_reference.md +1702 -0
  16. data/docs/index.md +73 -0
  17. data/docs/luoma_for_template_authors.md +3 -0
  18. data/docs/markdown.md +111 -0
  19. data/docs/predicate_reference.md +3 -0
  20. data/docs/static_analysis.md +3 -0
  21. data/docs/tag_reference.md +352 -0
  22. data/docs/template_loaders.md +177 -0
  23. data/docs/undefined_variables.md +3 -0
  24. data/docs-requirements.txt +11 -0
  25. data/docs_/cycle.md +17 -0
  26. data/docs_/font_rendering_example.md +157 -0
  27. data/docs_/header_block_example.md +51 -0
  28. data/docs_/increment_and_decrement.md +49 -0
  29. data/docs_/logo_style_example.md +40 -0
  30. data/docs_/migration.md +11 -0
  31. data/docs_/notes.md +19 -0
  32. data/lib/luoma/cache.rb +80 -0
  33. data/lib/luoma/chain_hash.rb +54 -0
  34. data/lib/luoma/context.rb +227 -0
  35. data/lib/luoma/drop.rb +84 -0
  36. data/lib/luoma/drops/block.rb +33 -0
  37. data/lib/luoma/drops/expression.rb +29 -0
  38. data/lib/luoma/drops/html_safe.rb +36 -0
  39. data/lib/luoma/drops/range.rb +69 -0
  40. data/lib/luoma/drops/undefined.rb +119 -0
  41. data/lib/luoma/environment.rb +479 -0
  42. data/lib/luoma/errors.rb +79 -0
  43. data/lib/luoma/escape.rb +35 -0
  44. data/lib/luoma/expression.rb +1271 -0
  45. data/lib/luoma/filter.rb +97 -0
  46. data/lib/luoma/filters/array.rb +372 -0
  47. data/lib/luoma/filters/date.rb +19 -0
  48. data/lib/luoma/filters/default.rb +16 -0
  49. data/lib/luoma/filters/json.rb +14 -0
  50. data/lib/luoma/filters/math.rb +84 -0
  51. data/lib/luoma/filters/size.rb +13 -0
  52. data/lib/luoma/filters/slice.rb +52 -0
  53. data/lib/luoma/filters/sort.rb +113 -0
  54. data/lib/luoma/filters/string.rb +186 -0
  55. data/lib/luoma/fnv.rb +15 -0
  56. data/lib/luoma/lexer.rb +106 -0
  57. data/lib/luoma/lexer_legacy.rb +396 -0
  58. data/lib/luoma/lexer_unified.rb +279 -0
  59. data/lib/luoma/loader.rb +50 -0
  60. data/lib/luoma/loaders/choice_loader.rb +20 -0
  61. data/lib/luoma/loaders/file_system_loader.rb +75 -0
  62. data/lib/luoma/loaders/mixins.rb +52 -0
  63. data/lib/luoma/markup.rb +109 -0
  64. data/lib/luoma/parser.rb +252 -0
  65. data/lib/luoma/parser_unified.rb +979 -0
  66. data/lib/luoma/predicates/blank.rb +19 -0
  67. data/lib/luoma/predicates/defined.rb +10 -0
  68. data/lib/luoma/predicates/empty.rb +15 -0
  69. data/lib/luoma/predicates/type.rb +35 -0
  70. data/lib/luoma/static_analysis.rb +427 -0
  71. data/lib/luoma/tags/assign.rb +63 -0
  72. data/lib/luoma/tags/break.rb +23 -0
  73. data/lib/luoma/tags/capture.rb +43 -0
  74. data/lib/luoma/tags/case.rb +137 -0
  75. data/lib/luoma/tags/comment.rb +17 -0
  76. data/lib/luoma/tags/continue.rb +23 -0
  77. data/lib/luoma/tags/define.rb +42 -0
  78. data/lib/luoma/tags/else.rb +30 -0
  79. data/lib/luoma/tags/for.rb +108 -0
  80. data/lib/luoma/tags/if.rb +109 -0
  81. data/lib/luoma/tags/import.rb +91 -0
  82. data/lib/luoma/tags/include.rb +77 -0
  83. data/lib/luoma/tags/output.rb +20 -0
  84. data/lib/luoma/tags/raw.rb +26 -0
  85. data/lib/luoma/tags/render.rb +92 -0
  86. data/lib/luoma/tags/with.rb +55 -0
  87. data/lib/luoma/template.rb +178 -0
  88. data/lib/luoma/token.rb +84 -0
  89. data/lib/luoma/version.rb +5 -0
  90. data/lib/luoma.rb +75 -0
  91. data/sig/luoma/cache.rbs +44 -0
  92. data/sig/luoma/chain_hash.rbs +26 -0
  93. data/sig/luoma/context.rbs +115 -0
  94. data/sig/luoma/drop.rbs +44 -0
  95. data/sig/luoma/drops/block.rbs +10 -0
  96. data/sig/luoma/drops/expression.rbs +10 -0
  97. data/sig/luoma/drops/html_safe.rbs +14 -0
  98. data/sig/luoma/drops/range.rbs +15 -0
  99. data/sig/luoma/drops/undefined.rbs +27 -0
  100. data/sig/luoma/environment.rbs +212 -0
  101. data/sig/luoma/errors.rbs +67 -0
  102. data/sig/luoma/escape.rbs +15 -0
  103. data/sig/luoma/expression.rbs +514 -0
  104. data/sig/luoma/filter.rbs +66 -0
  105. data/sig/luoma/filters/array.rbs +74 -0
  106. data/sig/luoma/filters/date.rbs +9 -0
  107. data/sig/luoma/filters/default.rbs +8 -0
  108. data/sig/luoma/filters/json.rbs +7 -0
  109. data/sig/luoma/filters/math.rbs +37 -0
  110. data/sig/luoma/filters/size.rbs +6 -0
  111. data/sig/luoma/filters/slice.rbs +10 -0
  112. data/sig/luoma/filters/sort.rbs +17 -0
  113. data/sig/luoma/filters/string.rbs +103 -0
  114. data/sig/luoma/fnv.rbs +3 -0
  115. data/sig/luoma/lexer.rbs +42 -0
  116. data/sig/luoma/lexer_legacy.rbs +81 -0
  117. data/sig/luoma/lexer_unified.rbs +49 -0
  118. data/sig/luoma/loader.rbs +40 -0
  119. data/sig/luoma/loaders/choice_loader.rbs +9 -0
  120. data/sig/luoma/loaders/file_system_loader.rbs +19 -0
  121. data/sig/luoma/loaders/mixins.rbs +16 -0
  122. data/sig/luoma/markup.rbs +68 -0
  123. data/sig/luoma/parser.rbs +105 -0
  124. data/sig/luoma/parser_unified.rbs +150 -0
  125. data/sig/luoma/predicates/blank.rbs +6 -0
  126. data/sig/luoma/predicates/defined.rbs +6 -0
  127. data/sig/luoma/predicates/empty.rbs +6 -0
  128. data/sig/luoma/predicates/type.rbs +21 -0
  129. data/sig/luoma/static_analysis.rbs +141 -0
  130. data/sig/luoma/tags/assign.rbs +12 -0
  131. data/sig/luoma/tags/break.rbs +11 -0
  132. data/sig/luoma/tags/capture.rbs +14 -0
  133. data/sig/luoma/tags/case.rbs +36 -0
  134. data/sig/luoma/tags/comment.rbs +11 -0
  135. data/sig/luoma/tags/continue.rbs +11 -0
  136. data/sig/luoma/tags/define.rbs +16 -0
  137. data/sig/luoma/tags/else.rbs +17 -0
  138. data/sig/luoma/tags/for.rbs +26 -0
  139. data/sig/luoma/tags/if.rbs +45 -0
  140. data/sig/luoma/tags/import.rbs +13 -0
  141. data/sig/luoma/tags/include.rbs +13 -0
  142. data/sig/luoma/tags/output.rbs +13 -0
  143. data/sig/luoma/tags/raw.rbs +11 -0
  144. data/sig/luoma/tags/render.rbs +13 -0
  145. data/sig/luoma/tags/with.rbs +15 -0
  146. data/sig/luoma/template.rbs +117 -0
  147. data/sig/luoma/token.rbs +81 -0
  148. data/sig/luoma.rbs +11 -0
  149. data/zensical.toml +363 -0
  150. data.tar.gz.sig +0 -0
  151. metadata +233 -0
  152. 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