liquid2 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 (84) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/.rubocop.yml +46 -0
  4. data/.ruby-version +1 -0
  5. data/.vscode/settings.json +32 -0
  6. data/CHANGELOG.md +5 -0
  7. data/LICENSE.txt +21 -0
  8. data/LICENSE_SHOPIFY.txt +20 -0
  9. data/README.md +219 -0
  10. data/Rakefile +23 -0
  11. data/Steepfile +26 -0
  12. data/lib/liquid2/context.rb +297 -0
  13. data/lib/liquid2/environment.rb +287 -0
  14. data/lib/liquid2/errors.rb +79 -0
  15. data/lib/liquid2/expression.rb +20 -0
  16. data/lib/liquid2/expressions/arguments.rb +25 -0
  17. data/lib/liquid2/expressions/array.rb +20 -0
  18. data/lib/liquid2/expressions/blank.rb +41 -0
  19. data/lib/liquid2/expressions/boolean.rb +20 -0
  20. data/lib/liquid2/expressions/filtered.rb +136 -0
  21. data/lib/liquid2/expressions/identifier.rb +43 -0
  22. data/lib/liquid2/expressions/lambda.rb +53 -0
  23. data/lib/liquid2/expressions/logical.rb +71 -0
  24. data/lib/liquid2/expressions/loop.rb +79 -0
  25. data/lib/liquid2/expressions/path.rb +33 -0
  26. data/lib/liquid2/expressions/range.rb +28 -0
  27. data/lib/liquid2/expressions/relational.rb +119 -0
  28. data/lib/liquid2/expressions/template_string.rb +20 -0
  29. data/lib/liquid2/filter.rb +95 -0
  30. data/lib/liquid2/filters/array.rb +202 -0
  31. data/lib/liquid2/filters/date.rb +20 -0
  32. data/lib/liquid2/filters/default.rb +16 -0
  33. data/lib/liquid2/filters/json.rb +15 -0
  34. data/lib/liquid2/filters/math.rb +87 -0
  35. data/lib/liquid2/filters/size.rb +11 -0
  36. data/lib/liquid2/filters/slice.rb +17 -0
  37. data/lib/liquid2/filters/sort.rb +96 -0
  38. data/lib/liquid2/filters/string.rb +204 -0
  39. data/lib/liquid2/loader.rb +59 -0
  40. data/lib/liquid2/loaders/file_system_loader.rb +76 -0
  41. data/lib/liquid2/loaders/mixins.rb +52 -0
  42. data/lib/liquid2/node.rb +113 -0
  43. data/lib/liquid2/nodes/comment.rb +18 -0
  44. data/lib/liquid2/nodes/output.rb +24 -0
  45. data/lib/liquid2/nodes/tags/assign.rb +35 -0
  46. data/lib/liquid2/nodes/tags/block_comment.rb +26 -0
  47. data/lib/liquid2/nodes/tags/capture.rb +40 -0
  48. data/lib/liquid2/nodes/tags/case.rb +111 -0
  49. data/lib/liquid2/nodes/tags/cycle.rb +63 -0
  50. data/lib/liquid2/nodes/tags/decrement.rb +29 -0
  51. data/lib/liquid2/nodes/tags/doc.rb +24 -0
  52. data/lib/liquid2/nodes/tags/echo.rb +31 -0
  53. data/lib/liquid2/nodes/tags/extends.rb +3 -0
  54. data/lib/liquid2/nodes/tags/for.rb +155 -0
  55. data/lib/liquid2/nodes/tags/if.rb +84 -0
  56. data/lib/liquid2/nodes/tags/include.rb +123 -0
  57. data/lib/liquid2/nodes/tags/increment.rb +29 -0
  58. data/lib/liquid2/nodes/tags/inline_comment.rb +28 -0
  59. data/lib/liquid2/nodes/tags/liquid.rb +29 -0
  60. data/lib/liquid2/nodes/tags/macro.rb +3 -0
  61. data/lib/liquid2/nodes/tags/raw.rb +30 -0
  62. data/lib/liquid2/nodes/tags/render.rb +137 -0
  63. data/lib/liquid2/nodes/tags/tablerow.rb +143 -0
  64. data/lib/liquid2/nodes/tags/translate.rb +3 -0
  65. data/lib/liquid2/nodes/tags/unless.rb +23 -0
  66. data/lib/liquid2/nodes/tags/with.rb +3 -0
  67. data/lib/liquid2/parser.rb +917 -0
  68. data/lib/liquid2/scanner.rb +595 -0
  69. data/lib/liquid2/static_analysis.rb +301 -0
  70. data/lib/liquid2/tag.rb +22 -0
  71. data/lib/liquid2/template.rb +182 -0
  72. data/lib/liquid2/undefined.rb +131 -0
  73. data/lib/liquid2/utils/cache.rb +80 -0
  74. data/lib/liquid2/utils/chain_hash.rb +40 -0
  75. data/lib/liquid2/utils/unescape.rb +119 -0
  76. data/lib/liquid2/version.rb +5 -0
  77. data/lib/liquid2.rb +90 -0
  78. data/performance/benchmark.rb +73 -0
  79. data/performance/memory_profile.rb +62 -0
  80. data/performance/profile.rb +71 -0
  81. data/sig/liquid2.rbs +2348 -0
  82. data.tar.gz.sig +0 -0
  83. metadata +164 -0
  84. metadata.gz.sig +0 -0
@@ -0,0 +1,595 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "utils/unescape"
4
+
5
+ module Liquid2
6
+ # Liquid template source text lexical scanner.
7
+ #
8
+ # This is a single pass tokenizer. We support tag and output delimiters inside string
9
+ # literals, so we must scan expressions as we go.
10
+ #
11
+ # We give comment and raw tags special consideration here.
12
+ class Scanner
13
+ attr_reader :tokens
14
+
15
+ RE_MARKUP_START = /\{[\{%#]/
16
+ RE_WHITESPACE = /[ \n\r\t]+/
17
+ RE_LINE_SPACE = /[ \t]+/
18
+ RE_WORD = /[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*/
19
+ RE_INT = /-?\d+(?:[eE]\+?\d+)?/
20
+ RE_FLOAT = /((?:-?\d+\.\d+(?:[eE][+-]?\d+)?)|(-?\d+[eE]-\d+))/
21
+ RE_PUNCTUATION = /\?|\[|\]|\|{1,2}|\.{1,2}|,|:|\(|\)|[<>=!]+/
22
+ RE_SINGLE_QUOTE_STRING_SPECIAL = /[\\'\$]/
23
+ RE_DOUBLE_QUOTE_STRING_SPECIAL = /[\\"\$]/
24
+
25
+ # Keywords and symbols that get their own token kind.
26
+ TOKEN_MAP = {
27
+ "true" => :token_true,
28
+ "false" => :token_false,
29
+ "nil" => :token_nil,
30
+ "null" => :token_nil,
31
+ "and" => :token_and,
32
+ "or" => :token_or,
33
+ "not" => :token_not,
34
+ "in" => :token_in,
35
+ "contains" => :token_contains,
36
+ "if" => :token_if,
37
+ "else" => :token_else,
38
+ "with" => :token_with,
39
+ "required" => :token_required,
40
+ "as" => :token_as,
41
+ "for" => :token_for,
42
+ "blank" => :token_blank,
43
+ "empty" => :token_empty,
44
+ "?" => :token_question,
45
+ "[" => :token_lbracket,
46
+ "]" => :token_rbracket,
47
+ "|" => :token_pipe,
48
+ "||" => :token_double_pipe,
49
+ "." => :token_dot,
50
+ ".." => :token_double_dot,
51
+ "," => :token_comma,
52
+ ":" => :token_colon,
53
+ "(" => :token_lparen,
54
+ ")" => :token_rparen,
55
+ "=" => :token_assign,
56
+ "<" => :token_lt,
57
+ "<=" => :token_le,
58
+ "<>" => :token_lg,
59
+ ">" => :token_gt,
60
+ ">=" => :token_ge,
61
+ "==" => :token_eq,
62
+ "!=" => :token_ne,
63
+ "=>" => :token_arrow
64
+ }.freeze
65
+
66
+ def self.tokenize(source, scanner)
67
+ lexer = new(source, scanner)
68
+ lexer.run
69
+ lexer.tokens
70
+ end
71
+
72
+ # @param source [String]
73
+ # @param scanner [StringScanner]
74
+ def initialize(source, scanner)
75
+ @source = source
76
+ @scanner = scanner
77
+ @scanner.string = @source
78
+
79
+ # A pointer to the start of the current token.
80
+ @start = 0
81
+
82
+ # Tokens are arrays of (kind, value, start index)
83
+ @tokens = [] # : Array[[Symbol, String?, Integer]]
84
+ end
85
+
86
+ def run
87
+ state = :lex_markup
88
+ state = send(state) until state.nil?
89
+ end
90
+
91
+ protected
92
+
93
+ # @param kind [Symbol]
94
+ # @param value [String?]
95
+ # @return void
96
+ def emit(kind, value)
97
+ # TODO: For debugging. Comment this out when benchmarking.
98
+ raise "empty span (#{kind}, #{value})" if @scanner.pos == @start
99
+
100
+ @tokens << [kind, value, @start]
101
+ @start = @scanner.pos
102
+ end
103
+
104
+ def skip_trivia
105
+ # TODO: For debugging. Comment this out when benchmarking.
106
+ raise "must emit before skipping trivia" if @scanner.pos != @start
107
+
108
+ @start = @scanner.pos if @scanner.skip(RE_WHITESPACE)
109
+ end
110
+
111
+ def skip_line_trivia
112
+ # TODO: For debugging. Comment this out when benchmarking.
113
+ raise "must emit before skipping line trivia" if @scanner.pos != @start
114
+
115
+ @start = @scanner.pos if @scanner.skip(RE_LINE_SPACE)
116
+ end
117
+
118
+ def accept_whitespace_control
119
+ # TODO: For debugging. Comment this out when benchmarking.
120
+ raise "must emit before accepting whitespace control" if @scanner.pos != @start
121
+
122
+ ch = @scanner.peek(1)
123
+
124
+ case ch
125
+ when "-", "+", "~"
126
+ @scanner.pos += 1
127
+ @tokens << [:token_whitespace_control, ch, @start]
128
+ @start = @scanner.pos
129
+ true
130
+ else
131
+ false
132
+ end
133
+ end
134
+
135
+ def lex_markup
136
+ case @scanner.scan(RE_MARKUP_START)
137
+ when "{#"
138
+ :lex_comment
139
+ when "{{"
140
+ @tokens << [:token_output_start, nil, @start]
141
+ @start = @scanner.pos
142
+ accept_whitespace_control
143
+ skip_trivia
144
+ :lex_expression
145
+ when "{%"
146
+ @tokens << [:token_tag_start, nil, @start]
147
+ @start = @scanner.pos
148
+ accept_whitespace_control
149
+ skip_trivia
150
+
151
+ if (tag_name = @scanner.scan(/(?:[a-z][a-z_0-9]*|#)/))
152
+ @tokens << [:token_tag_name, tag_name, @start]
153
+ @start = @scanner.pos
154
+
155
+ case tag_name
156
+ when "#"
157
+ # Don't skip trivia for inline comments.
158
+ # This is for consistency with other types of comments that include
159
+ # leading whitespace.
160
+ :lex_inside_inline_comment
161
+ when "comment"
162
+ skip_trivia
163
+ :lex_block_comment
164
+ when "doc"
165
+ skip_trivia
166
+ :lex_doc
167
+ when "raw"
168
+ skip_trivia
169
+ :lex_raw
170
+ when "liquid"
171
+ skip_trivia
172
+ :lex_line_statements
173
+ else
174
+ skip_trivia
175
+ :lex_expression
176
+ end
177
+ else
178
+ # Missing or malformed tag name
179
+ # Try to parse expr anyway
180
+ :lex_expression
181
+ end
182
+ else
183
+ if @scanner.skip_until(/\{[\{%#]/)
184
+ @scanner.pos -= 2
185
+ @tokens << [:token_other, @source.byteslice(@start...@scanner.pos), @start]
186
+ @start = @scanner.pos
187
+ :lex_markup
188
+ else
189
+ @scanner.terminate
190
+ if @start != @scanner.pos
191
+ @tokens << [:token_other, @source.byteslice(@start...@scanner.pos), @start]
192
+ @start = @scanner.pos
193
+ end
194
+ nil
195
+ end
196
+ end
197
+ end
198
+
199
+ def lex_expression
200
+ # TODO: For debugging. Comment this out when benchmarking.
201
+ raise "must emit before accepting an expression token" if @scanner.pos != @start
202
+
203
+ loop do
204
+ skip_trivia
205
+
206
+ case @scanner.get_byte
207
+ when "'"
208
+ @start = @scanner.pos
209
+ scan_string("'", :token_single_quote_string, RE_SINGLE_QUOTE_STRING_SPECIAL)
210
+ when "\""
211
+ @start = @scanner.pos
212
+ scan_string("\"", :token_double_quote_string, RE_DOUBLE_QUOTE_STRING_SPECIAL)
213
+ when nil
214
+ # End of scanner. Unclosed expression or string literal.
215
+ break
216
+ else
217
+ @scanner.pos -= 1
218
+ if (value = @scanner.scan(RE_FLOAT))
219
+ @tokens << [:token_float, value, @start]
220
+ @start = @scanner.pos
221
+ elsif (value = @scanner.scan(RE_INT))
222
+ @tokens << [:token_int, value, @start]
223
+ @start = @scanner.pos
224
+ elsif (value = @scanner.scan(RE_PUNCTUATION))
225
+ @tokens << [TOKEN_MAP[value] || :token_unknown, value, @start]
226
+ @start = @scanner.pos
227
+ elsif (value = @scanner.scan(RE_WORD))
228
+ @tokens << [TOKEN_MAP[value] || :token_word, value, @start]
229
+ @start = @scanner.pos
230
+ else
231
+ break
232
+ end
233
+ end
234
+ end
235
+
236
+ accept_whitespace_control
237
+
238
+ # Miro benchmarks show no performance gain using scan_byte and peek_byte over scan here.
239
+ case @scanner.scan(/[\}%]\}/)
240
+ when "}}"
241
+ @tokens << [:token_output_end, nil, @start]
242
+ when "%}"
243
+ @tokens << [:token_tag_end, nil, @start]
244
+ else
245
+ # Unexpected token
246
+ return nil if @scanner.eos?
247
+
248
+ if (ch = @scanner.scan(/[\}%]/))
249
+ raise LiquidSyntaxError.new("missing \"}\" or \"%\" detected",
250
+ [:token_unknown, ch, @start])
251
+ end
252
+
253
+ @tokens << [:token_unknown, @scanner.getch, @start]
254
+ end
255
+
256
+ @start = @scanner.pos
257
+ :lex_markup
258
+ end
259
+
260
+ def lex_comment
261
+ hash_count = 1
262
+
263
+ if (hashes = @scanner.scan(/#+/))
264
+ hash_count += hashes.length
265
+ end
266
+
267
+ @tokens << [:token_comment_start, @source.byteslice(@start...@scanner.pos), @start]
268
+ @start = @scanner.pos
269
+
270
+ wc = accept_whitespace_control
271
+
272
+ if @scanner.skip_until(/([+\-~]?)(\#{#{hash_count}}\})/)
273
+ @scanner.pos -= @scanner[0]&.length || 0
274
+ @tokens << [:token_comment, @source.byteslice(@start...@scanner.pos), @start]
275
+ @start = @scanner.pos
276
+
277
+ if (ch = @scanner[1]) && !ch.empty?
278
+ @tokens << [:token_whitespace_control, ch, @start]
279
+ @start = @scanner.pos += 1
280
+ end
281
+
282
+ if (end_comment = @scanner[2])
283
+ @scanner.pos += end_comment.length
284
+ @tokens << [:token_comment_end, @source.byteslice(@start...@scanner.pos), @start]
285
+ @start = @scanner.pos
286
+ end
287
+ else
288
+ # Fix the last one or two emitted tokens. They are not the start of a comment.
289
+ @tokens.pop if wc
290
+ @tokens.pop
291
+ start = (@tokens.pop || raise).last
292
+ @tokens << [:token_other, @source.byteslice(start...@scanner.pos), start]
293
+ end
294
+
295
+ :lex_markup
296
+ end
297
+
298
+ def lex_inside_inline_comment
299
+ if @scanner.skip_until(/([+\-~])?%\}/)
300
+ @scanner.pos -= @scanner.captures&.first.nil? ? 2 : 3
301
+ @tokens << [:token_comment, @source.byteslice(@start...@scanner.pos), @start]
302
+ @start = @scanner.pos
303
+ end
304
+
305
+ accept_whitespace_control
306
+
307
+ case @scanner.scan(/[\}%]\}/)
308
+ when "}}"
309
+ @tokens << [:token_output_end, nil, @start]
310
+ when "%}"
311
+ @tokens << [:token_tag_end, nil, @start]
312
+ else
313
+ # Unexpected token
314
+ return nil if @scanner.eos?
315
+
316
+ @tokens << [:token_unknown, @scanner.getch, @start]
317
+ end
318
+
319
+ @start = @scanner.pos
320
+ :lex_markup
321
+ end
322
+
323
+ def lex_raw
324
+ skip_trivia
325
+ accept_whitespace_control
326
+
327
+ case @scanner.scan(/[\}%]\}/)
328
+ when "}}"
329
+ @tokens << [:token_output_end, nil, @start]
330
+ @start = @scanner.pos
331
+ when "%}"
332
+ @tokens << [:token_tag_end, nil, @start]
333
+ @start = @scanner.pos
334
+ end
335
+
336
+ if @scanner.skip_until(/(\{%[+\-~]?\s*endraw\s*[+\-~]?%\})/)
337
+ @scanner.pos -= @scanner.captures&.first&.length || raise
338
+ @tokens << [:token_raw, @source.byteslice(@start...@scanner.pos), @start]
339
+ @start = @scanner.pos
340
+ end
341
+
342
+ :lex_markup
343
+ end
344
+
345
+ def lex_block_comment
346
+ skip_trivia
347
+ accept_whitespace_control
348
+
349
+ case @scanner.scan(/[\}%]\}/)
350
+ when "}}"
351
+ @tokens << [:token_output_end, nil, @start]
352
+ @start = @scanner.pos
353
+ when "%}"
354
+ @tokens << [:token_tag_end, nil, @start]
355
+ @start = @scanner.pos
356
+ end
357
+
358
+ comment_depth = 1
359
+ raw_depth = 0
360
+
361
+ loop do
362
+ unless @scanner.skip_until(/(\{%[+\-~]?\s*(comment|raw|endcomment|endraw)\s*[+\-~]?%\})/)
363
+ break
364
+ end
365
+
366
+ tag_name = @scanner.captures&.last || raise
367
+
368
+ case tag_name
369
+ when "comment"
370
+ comment_depth += 1
371
+ when "raw"
372
+ raw_depth += 1
373
+ when "endraw"
374
+ raw_depth -= 1 if raw_depth.positive?
375
+ when "endcomment"
376
+ next if raw_depth.positive?
377
+
378
+ comment_depth -= 1
379
+ next if comment_depth.positive?
380
+
381
+ @scanner.pos -= @scanner.captures&.first&.length || raise
382
+ @tokens << [:token_comment, @source.byteslice(@start...@scanner.pos), @start]
383
+ @start = @scanner.pos
384
+ break
385
+ else
386
+ raise "unreachable"
387
+ end
388
+ end
389
+
390
+ :lex_markup
391
+ end
392
+
393
+ def lex_doc
394
+ skip_trivia
395
+ accept_whitespace_control
396
+
397
+ case @scanner.scan(/[\}%]\}/)
398
+ when "}}"
399
+ @tokens << [:token_output_end, nil, @start]
400
+ @start = @scanner.pos
401
+ when "%}"
402
+ @tokens << [:token_tag_end, nil, @start]
403
+ @start = @scanner.pos
404
+ end
405
+
406
+ if @scanner.skip_until(/(\{%[+\-~]?\s*enddoc\s*[+\-~]?%\})/)
407
+ @scanner.pos -= @scanner.captures&.first&.length || raise
408
+ @tokens << [:token_doc, @source.byteslice(@start...@scanner.pos), @start]
409
+ @start = @scanner.pos
410
+ end
411
+
412
+ :lex_markup
413
+ end
414
+
415
+ def lex_line_statements
416
+ # TODO: For debugging. Comment this out when benchmarking.
417
+ raise "must emit before accepting an expression token" if @scanner.pos != @start
418
+
419
+ skip_trivia # Leading newlines are OK
420
+
421
+ if (tag_name = @scanner.scan(/(?:[a-z][a-z_0-9]*|#)/))
422
+ @tokens << [:token_tag_start, nil, @start]
423
+ @tokens << [:token_tag_name, tag_name, @start]
424
+ @start = @scanner.pos
425
+
426
+ if tag_name == "#" && @scanner.scan_until(/([\r\n]+|-?%\})/)
427
+ @scanner.pos -= @scanner.captures&.first&.length || raise
428
+ @tokens << [:token_comment, @source.byteslice(@start...@scanner.pos), @start]
429
+ @start = @scanner.pos
430
+ @tokens << [:token_tag_end, nil, @start]
431
+ :lex_line_statements
432
+
433
+ elsif tag_name == "comment" && @scanner.scan_until(/(endcomment)/)
434
+ @tokens << [:token_tag_end, nil, @start]
435
+ @scanner.pos -= @scanner.captures&.first&.length || raise
436
+ @tokens << [:token_comment, @source.byteslice(@start...@scanner.pos), @start]
437
+ @start = @scanner.pos
438
+ :lex_line_statements
439
+ else
440
+ :lex_inside_line_statement
441
+ end
442
+ else
443
+ accept_whitespace_control
444
+ case @scanner.scan(/[\}%]\}/)
445
+ when "}}"
446
+ @tokens << [:token_output_end, nil, @start]
447
+ @start = @scanner.pos
448
+ when "%}"
449
+ @tokens << [:token_tag_end, nil, @start]
450
+ @start = @scanner.pos
451
+ end
452
+
453
+ :lex_markup
454
+ end
455
+ end
456
+
457
+ def lex_inside_line_statement
458
+ loop do
459
+ skip_line_trivia
460
+
461
+ case @scanner.get_byte
462
+ when "'"
463
+ @start = @scanner.pos
464
+ scan_string("'", :token_single_quote_string, RE_SINGLE_QUOTE_STRING_SPECIAL)
465
+ when "\""
466
+ @start = @scanner.pos
467
+ scan_string("\"", :token_double_quote_string, RE_DOUBLE_QUOTE_STRING_SPECIAL)
468
+ when nil
469
+ # End of scanner. Unclosed expression or string literal.
470
+ break
471
+
472
+ else
473
+ @scanner.pos -= 1
474
+ if (value = @scanner.scan(RE_FLOAT))
475
+ @tokens << [:token_float, value, @start]
476
+ @start = @scanner.pos
477
+ elsif (value = @scanner.scan(RE_INT))
478
+ @tokens << [:token_int, value, @start]
479
+ @start = @scanner.pos
480
+ elsif (value = @scanner.scan(RE_PUNCTUATION))
481
+ @tokens << [TOKEN_MAP[value] || raise, nil, @start]
482
+ @start = @scanner.pos
483
+ elsif (value = @scanner.scan(RE_WORD))
484
+ @tokens << [TOKEN_MAP[value] || :token_word, value, @start]
485
+ @start = @scanner.pos
486
+ elsif @scanner.scan(/(\r?\n)+/)
487
+ # End of the line statement
488
+ @tokens << [:token_tag_end, nil, @start]
489
+ @start = @scanner.pos
490
+ return :lex_line_statements
491
+ else
492
+ # End of the line statement and enclosing `liquid` tag.
493
+ @tokens << [:token_tag_end, nil, @start]
494
+ accept_whitespace_control
495
+ case @scanner.scan(/[\}%]\}/)
496
+ when "}}"
497
+ @tokens << [:token_output_end, nil, @start]
498
+ @start = @scanner.pos
499
+ when "%}"
500
+ @tokens << [:token_tag_end, nil, @start]
501
+ @start = @scanner.pos
502
+ end
503
+
504
+ return :lex_markup
505
+ end
506
+ end
507
+ end
508
+ end
509
+
510
+ # Scan a string literal surrounded by single quotes.
511
+ # Assumes the opening quote has already been consumed and emitted.
512
+ def scan_string(quote, symbol, pattern)
513
+ start_of_string = @start - 1
514
+ needs_unescaping = false
515
+
516
+ loop do
517
+ @scanner.pos -= 1 if @scanner.skip_until(pattern)
518
+ case @scanner.get_byte
519
+ when quote
520
+ # @type var token: [Symbol, String, Integer]
521
+ token = [symbol, @source.byteslice(@start...@scanner.pos - 1) || raise, @start]
522
+ token[1] = Liquid2.unescape_string(token[1], quote, token) if needs_unescaping
523
+ @tokens << token
524
+ @start = @scanner.pos
525
+ needs_unescaping = false
526
+ return
527
+ when "\\"
528
+ # An escape sequence. Move past the next character.
529
+ @scanner.get_byte
530
+ needs_unescaping = true
531
+ when "$"
532
+ next unless @scanner.peek(1) == "{"
533
+
534
+ # The start of a `${` expression.
535
+ # Emit what we have so far. This could be empty if the template string
536
+ # starts with `${`.
537
+ # @type var token: [Symbol, String, Integer]
538
+ token = [symbol, @source.byteslice(@start...@scanner.pos - 1) || raise, @start]
539
+ token[1] = Liquid2.unescape_string(token[1], quote, token) if needs_unescaping
540
+ @tokens << token
541
+
542
+ @start = @scanner.pos
543
+ needs_unescaping = false
544
+
545
+ # Emit and move past `${`
546
+ @tokens << [:token_string_interpol_start, nil, @start]
547
+ @scanner.pos += 1
548
+ @start = @scanner.pos
549
+
550
+ loop do
551
+ skip_trivia
552
+
553
+ case @scanner.get_byte
554
+ when "'"
555
+ @start = @scanner.pos
556
+ scan_string("'", :token_single_quote_string, RE_SINGLE_QUOTE_STRING_SPECIAL)
557
+ when "\""
558
+ @start = @scanner.pos
559
+ scan_string("\"", :token_double_quote_string, RE_DOUBLE_QUOTE_STRING_SPECIAL)
560
+ when "}"
561
+ @tokens << [:token_string_interpol_end, nil, @start]
562
+ @start = @scanner.pos
563
+ break
564
+ when nil
565
+ # End of scanner. Unclosed expression or string literal.
566
+ raise LiquidSyntaxError.new("unclosed string literal or template string expression",
567
+ [symbol, nil, start_of_string])
568
+ else
569
+ @scanner.pos -= 1
570
+ if (value = @scanner.scan(RE_FLOAT))
571
+ @tokens << [:token_float, value, @start]
572
+ @start = @scanner.pos
573
+ elsif (value = @scanner.scan(RE_INT))
574
+ @tokens << [:token_int, value, @start]
575
+ @start = @scanner.pos
576
+ elsif (value = @scanner.scan(RE_PUNCTUATION))
577
+ @tokens << [TOKEN_MAP[value] || raise, nil, @start]
578
+ @start = @scanner.pos
579
+ elsif (value = @scanner.scan(RE_WORD))
580
+ @tokens << [TOKEN_MAP[value] || :token_word, value, @start]
581
+ @start = @scanner.pos
582
+ else
583
+ break
584
+ end
585
+ end
586
+ end
587
+ when nil
588
+ # End of scanner. Unclosed string literal.
589
+ raise LiquidSyntaxError.new("unclosed string literal or template string expression",
590
+ [symbol, nil, start_of_string])
591
+ end
592
+ end
593
+ end
594
+ end
595
+ end