foxtail-tools 0.5.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +66 -0
  5. data/exe/foxtail +12 -0
  6. data/lib/foxtail/cli/commands/check.rb +60 -0
  7. data/lib/foxtail/cli/commands/dump.rb +43 -0
  8. data/lib/foxtail/cli/commands/ids.rb +73 -0
  9. data/lib/foxtail/cli/commands/tidy.rb +107 -0
  10. data/lib/foxtail/cli.rb +59 -0
  11. data/lib/foxtail/syntax/error.rb +8 -0
  12. data/lib/foxtail/syntax/parser/ast/annotation.rb +23 -0
  13. data/lib/foxtail/syntax/parser/ast/attribute.rb +23 -0
  14. data/lib/foxtail/syntax/parser/ast/base_comment.rb +19 -0
  15. data/lib/foxtail/syntax/parser/ast/base_literal.rb +24 -0
  16. data/lib/foxtail/syntax/parser/ast/base_node.rb +89 -0
  17. data/lib/foxtail/syntax/parser/ast/call_arguments.rb +23 -0
  18. data/lib/foxtail/syntax/parser/ast/comment.rb +13 -0
  19. data/lib/foxtail/syntax/parser/ast/function_reference.rb +23 -0
  20. data/lib/foxtail/syntax/parser/ast/group_comment.rb +13 -0
  21. data/lib/foxtail/syntax/parser/ast/identifier.rb +19 -0
  22. data/lib/foxtail/syntax/parser/ast/junk.rb +23 -0
  23. data/lib/foxtail/syntax/parser/ast/message.rb +28 -0
  24. data/lib/foxtail/syntax/parser/ast/message_reference.rb +23 -0
  25. data/lib/foxtail/syntax/parser/ast/named_argument.rb +23 -0
  26. data/lib/foxtail/syntax/parser/ast/number_literal.rb +24 -0
  27. data/lib/foxtail/syntax/parser/ast/pattern.rb +22 -0
  28. data/lib/foxtail/syntax/parser/ast/placeable.rb +21 -0
  29. data/lib/foxtail/syntax/parser/ast/resource.rb +55 -0
  30. data/lib/foxtail/syntax/parser/ast/resource_comment.rb +13 -0
  31. data/lib/foxtail/syntax/parser/ast/select_expression.rb +23 -0
  32. data/lib/foxtail/syntax/parser/ast/span.rb +22 -0
  33. data/lib/foxtail/syntax/parser/ast/string_literal.rb +45 -0
  34. data/lib/foxtail/syntax/parser/ast/syntax_node.rb +22 -0
  35. data/lib/foxtail/syntax/parser/ast/term.rb +28 -0
  36. data/lib/foxtail/syntax/parser/ast/term_reference.rb +25 -0
  37. data/lib/foxtail/syntax/parser/ast/text_element.rb +19 -0
  38. data/lib/foxtail/syntax/parser/ast/variable_reference.rb +21 -0
  39. data/lib/foxtail/syntax/parser/ast/variant.rb +25 -0
  40. data/lib/foxtail/syntax/parser/ast.rb +12 -0
  41. data/lib/foxtail/syntax/parser/parse_error.rb +94 -0
  42. data/lib/foxtail/syntax/parser/stream.rb +338 -0
  43. data/lib/foxtail/syntax/parser.rb +797 -0
  44. data/lib/foxtail/syntax/serializer.rb +242 -0
  45. data/lib/foxtail/syntax/visitor.rb +61 -0
  46. data/lib/foxtail/syntax.rb +12 -0
  47. data/lib/foxtail/tools/error.rb +8 -0
  48. data/lib/foxtail/tools/version.rb +9 -0
  49. data/lib/foxtail-tools.rb +22 -0
  50. metadata +141 -0
@@ -0,0 +1,797 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Syntax
5
+ # Ruby equivalent of fluent.js FluentParser
6
+ # Translates TypeScript parsing logic to Ruby
7
+ class Parser
8
+ TRAILING_WS_RE = /[ \n\r]+\z/
9
+ private_constant :TRAILING_WS_RE
10
+
11
+ # Define Indent as a Struct for temporary indentation tokens
12
+ # Note: Uses Struct instead of Data.define because the value field is mutated in dedent()
13
+ Indent = Struct.new(:value, :start, :end, :span)
14
+
15
+ # Create a new Parser instance
16
+ # @param with_spans [Boolean] Whether to include span information in AST nodes (default: true)
17
+ def initialize(with_spans: true)
18
+ @with_spans = with_spans
19
+ end
20
+
21
+ # @return [Boolean] Whether to include span information in AST nodes
22
+ def with_spans? = @with_spans
23
+
24
+ # Main entry point - parse FTL source into AST
25
+ # @param source [String] FTL source text to parse
26
+ # @return [Parser::AST::Resource]
27
+ def parse(source)
28
+ ps = Stream.new(source)
29
+ ps.skip_blank_block
30
+
31
+ entries = []
32
+ last_comment = nil
33
+
34
+ while ps.current_char
35
+ entry = get_entry_or_junk(ps)
36
+ blank_lines = ps.skip_blank_block
37
+
38
+ # Regular Comments require special logic. Comments may be attached to
39
+ # Messages or Terms if they are followed immediately by them. However
40
+ # they should parse as standalone when they're followed by AST::Junk.
41
+ # Consequently, we only attach Comments once we know that the AST::Message
42
+ # or the AST::Term parsed successfully.
43
+ if entry.is_a?(AST::Comment) && blank_lines.length == 0 && ps.current_char
44
+ # Stash the comment and decide what to do with it in the next pass.
45
+ last_comment = entry
46
+ next
47
+ end
48
+
49
+ if last_comment
50
+ if entry.is_a?(AST::Message) || entry.is_a?(AST::Term)
51
+ entry.comment = last_comment
52
+ if @with_spans && entry.span && last_comment.span
53
+ entry.span.start = last_comment.span.start
54
+ end
55
+ else
56
+ entries << last_comment
57
+ end
58
+ # In either case, the stashed comment has been dealt with; clear it.
59
+ last_comment = nil
60
+ end
61
+
62
+ # No special logic for other types of entries.
63
+ entries << entry
64
+ end
65
+
66
+ res = AST::Resource.new(entries)
67
+ if @with_spans
68
+ res.add_span(0, ps.index)
69
+ end
70
+
71
+ res
72
+ end
73
+
74
+ # Parse the first AST::Message or AST::Term in source
75
+ # @param source [String] FTL source text to parse
76
+ # @return [Parser::AST::Message, Parser::AST::Term, Parser::AST::Junk]
77
+ def parse_entry(source)
78
+ ps = Stream.new(source)
79
+ ps.skip_blank_block
80
+
81
+ while ps.current_char == "#"
82
+ skipped = get_entry_or_junk(ps)
83
+ return skipped if skipped.is_a?(AST::Junk)
84
+
85
+ ps.skip_blank_block
86
+ end
87
+
88
+ get_entry_or_junk(ps)
89
+ end
90
+
91
+ private def get_entry_or_junk(ps)
92
+ entry_start_pos = ps.index
93
+
94
+ begin
95
+ entry = get_entry(ps)
96
+ ps.expect_line_end
97
+ entry
98
+ rescue ParseError => e
99
+ error_index = ps.index
100
+ ps.skip_to_next_entry_start(entry_start_pos)
101
+ next_entry_start = ps.index
102
+
103
+ if next_entry_start < error_index
104
+ # The position of the error must be inside of the Junk's span.
105
+ error_index = next_entry_start
106
+ end
107
+
108
+ # Create a AST::Junk instance
109
+ slice = ps.string[entry_start_pos...next_entry_start]
110
+ junk = AST::Junk.new(slice)
111
+
112
+ if @with_spans
113
+ junk.add_span(entry_start_pos, next_entry_start)
114
+ end
115
+
116
+ annot = AST::Annotation.new(e.code, e.args, e.message)
117
+ if @with_spans
118
+ annot.add_span(error_index, error_index)
119
+ end
120
+ junk.annotations << annot
121
+
122
+ junk
123
+ end
124
+ end
125
+
126
+ private def get_entry(ps)
127
+ case ps.current_char
128
+ when "#"
129
+ get_comment(ps)
130
+ when "-"
131
+ get_term(ps)
132
+ else
133
+ raise ParseError, "E0002" unless ps.identifier_start?
134
+
135
+ get_message(ps)
136
+ end
137
+ end
138
+
139
+ private def get_comment(ps)
140
+ start_pos = ps.index if @with_spans
141
+
142
+ # 0 - comment, 1 - group comment, 2 - resource comment
143
+ level = -1
144
+ content = ""
145
+
146
+ loop do
147
+ i = -1
148
+ while ps.current_char == "#" && i < (level == -1 ? 2 : level)
149
+ ps.next
150
+ i += 1
151
+ end
152
+
153
+ level = i if level == -1
154
+
155
+ if ps.current_char != Stream::EOL
156
+ ps.expect_char(" ")
157
+ while (ch = ps.take_char {|x| x != Stream::EOL })
158
+ content += ch
159
+ end
160
+ end
161
+
162
+ break unless ps.next_line_comment?(level)
163
+
164
+ content += ps.current_char
165
+ ps.next
166
+ end
167
+
168
+ result = comment_class_for_level(level).new(content)
169
+ add_span_if_enabled(result, ps, start_pos)
170
+ result
171
+ end
172
+
173
+ private def comment_class_for_level(level)
174
+ case level
175
+ when 0 then AST::Comment
176
+ when 1 then AST::GroupComment
177
+ else AST::ResourceComment
178
+ end
179
+ end
180
+
181
+ private def get_message(ps)
182
+ start_pos = ps.index if @with_spans
183
+
184
+ id = get_identifier(ps)
185
+ ps.skip_blank_inline
186
+ ps.expect_char("=")
187
+
188
+ value = maybe_get_pattern(ps)
189
+ attrs = get_attributes(ps)
190
+
191
+ if value.nil? && attrs.empty?
192
+ raise ParseError.new("E0005", id.name)
193
+ end
194
+
195
+ result = AST::Message.new(id, value, attrs)
196
+ add_span_if_enabled(result, ps, start_pos)
197
+ result
198
+ end
199
+
200
+ private def get_term(ps)
201
+ start_pos = ps.index if @with_spans
202
+
203
+ ps.expect_char("-")
204
+ id = get_identifier(ps)
205
+ ps.skip_blank_inline
206
+ ps.expect_char("=")
207
+
208
+ value = maybe_get_pattern(ps)
209
+ if value.nil?
210
+ raise ParseError.new("E0006", id.name)
211
+ end
212
+
213
+ attrs = get_attributes(ps)
214
+ result = AST::Term.new(id, value, attrs)
215
+ add_span_if_enabled(result, ps, start_pos)
216
+ result
217
+ end
218
+
219
+ private def get_attribute(ps)
220
+ start_pos = ps.index if @with_spans
221
+
222
+ ps.expect_char(".")
223
+ key = get_identifier(ps)
224
+ ps.skip_blank_inline
225
+ ps.expect_char("=")
226
+
227
+ value = maybe_get_pattern(ps)
228
+ if value.nil?
229
+ raise ParseError, "E0012"
230
+ end
231
+
232
+ result = AST::Attribute.new(key, value)
233
+ add_span_if_enabled(result, ps, start_pos)
234
+ result
235
+ end
236
+
237
+ private def get_attributes(ps)
238
+ attrs = []
239
+ ps.peek_blank
240
+
241
+ while ps.attribute_start?
242
+ ps.skip_to_peek
243
+ attr = get_attribute(ps)
244
+ attrs << attr
245
+ ps.peek_blank
246
+ end
247
+
248
+ attrs
249
+ end
250
+
251
+ private def get_identifier(ps)
252
+ start_pos = ps.index if @with_spans
253
+
254
+ name = ps.take_id_start
255
+
256
+ while (ch = ps.take_id_char)
257
+ name += ch
258
+ end
259
+
260
+ result = AST::Identifier.new(name)
261
+ add_span_if_enabled(result, ps, start_pos)
262
+ result
263
+ end
264
+
265
+ private def get_variant_key(ps)
266
+ ch = ps.current_char
267
+
268
+ if ch == Stream::EOF
269
+ raise ParseError, "E0013"
270
+ end
271
+
272
+ cc = ch.ord
273
+ if cc.between?(48, 57) || cc == 45 # 0-9, -
274
+ get_number(ps)
275
+ else
276
+ get_identifier(ps)
277
+ end
278
+ end
279
+
280
+ private def get_variant(ps, has_default: false)
281
+ start_pos = ps.index if @with_spans
282
+ default_index = false
283
+
284
+ if ps.current_char == "*"
285
+ if has_default
286
+ raise ParseError, "E0015"
287
+ end
288
+
289
+ ps.next
290
+ default_index = true
291
+ end
292
+
293
+ ps.expect_char("[")
294
+ ps.skip_blank
295
+ key = get_variant_key(ps)
296
+ ps.skip_blank
297
+ ps.expect_char("]")
298
+
299
+ value = maybe_get_pattern(ps)
300
+ if value.nil?
301
+ raise ParseError, "E0012"
302
+ end
303
+
304
+ result = AST::Variant.new(key, value, default: default_index)
305
+ add_span_if_enabled(result, ps, start_pos)
306
+ result
307
+ end
308
+
309
+ private def get_variants(ps)
310
+ variants = []
311
+ has_default = false
312
+
313
+ ps.skip_blank
314
+ while ps.variant_start?
315
+ variant = get_variant(ps, has_default:)
316
+ has_default = true if variant.default
317
+ variants << variant
318
+ ps.expect_line_end
319
+ ps.skip_blank
320
+ end
321
+
322
+ if variants.empty?
323
+ raise ParseError, "E0011"
324
+ end
325
+
326
+ unless has_default
327
+ raise ParseError, "E0010"
328
+ end
329
+
330
+ variants
331
+ end
332
+
333
+ private def get_digits(ps)
334
+ num = ""
335
+
336
+ while (ch = ps.take_digit)
337
+ num += ch
338
+ end
339
+
340
+ if num.empty?
341
+ raise ParseError.new("E0004", "0-9")
342
+ end
343
+
344
+ num
345
+ end
346
+
347
+ private def get_number(ps)
348
+ start_pos = ps.index if @with_spans
349
+ value = ""
350
+
351
+ if ps.current_char == "-"
352
+ ps.next
353
+ value += "-#{get_digits(ps)}"
354
+ else
355
+ value += get_digits(ps)
356
+ end
357
+
358
+ if ps.current_char == "."
359
+ ps.next
360
+ value += ".#{get_digits(ps)}"
361
+ end
362
+
363
+ result = AST::NumberLiteral.new(value)
364
+ add_span_if_enabled(result, ps, start_pos)
365
+ result
366
+ end
367
+
368
+ private def maybe_get_pattern(ps)
369
+ ps.peek_blank_inline
370
+ if ps.value_start?
371
+ ps.skip_to_peek
372
+ return get_pattern(ps, false)
373
+ end
374
+
375
+ ps.peek_blank_block
376
+ if ps.value_continuation?
377
+ ps.skip_to_peek
378
+ return get_pattern(ps, true)
379
+ end
380
+
381
+ nil
382
+ end
383
+
384
+ private def get_pattern(ps, is_block)
385
+ start_pos = ps.index if @with_spans
386
+ elements = []
387
+ common_indent_length = nil
388
+
389
+ if is_block
390
+ # A block pattern is a pattern which starts on a new line. Store and
391
+ # measure the indent of this first line for the dedentation logic.
392
+ blank_start = ps.index
393
+ first_indent = ps.skip_blank_inline
394
+ elements << get_indent(ps, first_indent, blank_start)
395
+ common_indent_length = first_indent.length
396
+ else
397
+ common_indent_length = Float::INFINITY
398
+ end
399
+
400
+ loop do
401
+ ch = ps.current_char
402
+ break unless ch
403
+
404
+ case ch
405
+ when Stream::EOL
406
+ blank_start = ps.index
407
+ blank_lines = ps.peek_blank_block
408
+ if ps.value_continuation?
409
+ ps.skip_to_peek
410
+ indent = ps.skip_blank_inline
411
+ common_indent_length = [common_indent_length, indent.length].min
412
+ elements << get_indent(ps, blank_lines + indent, blank_start)
413
+ next
414
+ end
415
+
416
+ # The end condition for getPattern's while loop is a newline
417
+ # which is not followed by a valid pattern continuation.
418
+ ps.reset_peek
419
+ break
420
+ when "{"
421
+ elements << get_placeable(ps)
422
+ next
423
+ when "}"
424
+ raise ParseError, "E0027"
425
+ else
426
+ elements << get_text_element(ps)
427
+ end
428
+ end
429
+
430
+ dedented = dedent(elements, common_indent_length)
431
+ result = AST::Pattern.new(dedented)
432
+ add_span_if_enabled(result, ps, start_pos)
433
+ result
434
+ end
435
+
436
+ # Create a token representing an indent. It's not part of the AST and it will
437
+ # be trimmed and merged into adjacent TextElements, or turned into a new
438
+ # AST::TextElement, if it's surrounded by two Placeables.
439
+ private def get_indent(ps, value, start)
440
+ span = @with_spans ? AST::Span.new(start, ps.index) : nil
441
+ Indent.new(value:, start:, end: ps.index, span:)
442
+ end
443
+
444
+ # Dedent a list of elements by removing the maximum common indent from the
445
+ # beginning of text lines. The common indent is calculated in get_pattern.
446
+ private def dedent(elements, common_indent)
447
+ trimmed = []
448
+
449
+ elements.each do |element|
450
+ if element.is_a?(AST::Placeable)
451
+ trimmed << element
452
+ next
453
+ end
454
+
455
+ if element.is_a?(Indent)
456
+ # Strip common indent.
457
+ element.value = element.value[0...(element.value.length - common_indent)]
458
+ next if element.value.empty?
459
+ end
460
+
461
+ prev = trimmed.last
462
+ if prev.is_a?(AST::TextElement)
463
+ # Join adjacent TextElements by replacing them with their sum.
464
+ sum = AST::TextElement.new(prev.value + element.value)
465
+ if @with_spans && prev.span && element.span
466
+ sum.add_span(prev.span.start, element.span.end)
467
+ end
468
+ trimmed[-1] = sum
469
+ next
470
+ end
471
+
472
+ if element.is_a?(Indent)
473
+ # If the indent hasn't been merged into a preceding AST::TextElement,
474
+ # convert it into a new AST::TextElement.
475
+ text_element = AST::TextElement.new(element.value)
476
+ if @with_spans && element.span
477
+ text_element.add_span(element.span.start, element.span.end)
478
+ end
479
+ element = text_element
480
+ end
481
+
482
+ trimmed << element
483
+ end
484
+
485
+ # Trim trailing whitespace from the AST::Pattern.
486
+ last_element = trimmed.last
487
+ if last_element.is_a?(AST::TextElement)
488
+ last_element.value = last_element.value.gsub(TRAILING_WS_RE, "")
489
+ trimmed.pop if last_element.value.empty?
490
+ end
491
+
492
+ trimmed
493
+ end
494
+
495
+ private def get_text_element(ps)
496
+ start_pos = ps.index if @with_spans
497
+ buffer = ""
498
+
499
+ loop do
500
+ ch = ps.current_char
501
+ break unless ch
502
+
503
+ if ch == "{" || ch == "}"
504
+ break
505
+ end
506
+
507
+ if ch == Stream::EOL
508
+ break
509
+ end
510
+
511
+ buffer += ch
512
+ ps.next
513
+ end
514
+
515
+ result = AST::TextElement.new(buffer)
516
+ add_span_if_enabled(result, ps, start_pos)
517
+ result
518
+ end
519
+
520
+ private def get_placeable(ps)
521
+ start_pos = ps.index if @with_spans
522
+
523
+ ps.expect_char("{")
524
+ ps.skip_blank
525
+ expression = get_expression(ps)
526
+ ps.expect_char("}")
527
+
528
+ result = AST::Placeable.new(expression)
529
+ add_span_if_enabled(result, ps, start_pos)
530
+ result
531
+ end
532
+
533
+ # Helper method to add spans consistently
534
+ private def add_span_if_enabled(node, ps, start_pos=nil)
535
+ return unless @with_spans
536
+
537
+ start_pos ||= ps.index
538
+ node.add_span(start_pos, ps.index) unless node.span
539
+ end
540
+
541
+ private def get_expression(ps)
542
+ start_pos = ps.index if @with_spans
543
+
544
+ selector = get_inline_expression(ps)
545
+ ps.skip_blank
546
+
547
+ if ps.current_char == "-"
548
+ if ps.peek != ">"
549
+ ps.reset_peek
550
+ return selector
551
+ end
552
+
553
+ # Validate selector expression according to
554
+ # abstract.js in the Fluent specification
555
+ case selector
556
+ when AST::MessageReference
557
+ raise ParseError, "E0016" if selector.attribute.nil?
558
+
559
+ raise ParseError, "E0018"
560
+ when AST::TermReference
561
+ if selector.attribute.nil?
562
+ raise ParseError, "E0017"
563
+ end
564
+ when AST::Placeable
565
+ raise ParseError, "E0029"
566
+ end
567
+
568
+ ps.next
569
+ ps.next
570
+
571
+ ps.skip_blank_inline
572
+ ps.expect_line_end
573
+
574
+ variants = get_variants(ps)
575
+ result = AST::SelectExpression.new(selector, variants)
576
+ add_span_if_enabled(result, ps, start_pos)
577
+ return result
578
+ end
579
+
580
+ if selector.is_a?(AST::TermReference) && !selector.attribute.nil?
581
+ raise ParseError, "E0019"
582
+ end
583
+
584
+ selector
585
+ end
586
+
587
+ private def get_inline_expression(ps)
588
+ start_pos = ps.index if @with_spans
589
+
590
+ if ps.current_char == "{"
591
+ return get_placeable(ps)
592
+ end
593
+
594
+ if ps.number_start?
595
+ return get_number(ps)
596
+ end
597
+
598
+ if ps.current_char == '"'
599
+ return get_string(ps)
600
+ end
601
+
602
+ if ps.current_char == "$"
603
+ ps.next
604
+ id = get_identifier(ps)
605
+ result = AST::VariableReference.new(id)
606
+ add_span_if_enabled(result, ps, start_pos)
607
+ return result
608
+ end
609
+
610
+ if ps.current_char == "-"
611
+ ps.next
612
+ id = get_identifier(ps)
613
+
614
+ attr = nil
615
+ if ps.current_char == "."
616
+ ps.next
617
+ attr = get_identifier(ps)
618
+ end
619
+
620
+ args = nil
621
+ ps.peek_blank
622
+ if ps.current_peek == "("
623
+ ps.skip_to_peek
624
+ args = get_call_arguments(ps)
625
+ end
626
+
627
+ result = AST::TermReference.new(id, attr, args)
628
+ add_span_if_enabled(result, ps, start_pos)
629
+ return result
630
+ end
631
+
632
+ if ps.identifier_start?
633
+ id = get_identifier(ps)
634
+ ps.peek_blank
635
+
636
+ if ps.current_peek == "("
637
+ # It's a Function. Ensure it's all upper-case.
638
+ unless /^[A-Z][A-Z0-9_-]*$/.match?(id.name)
639
+ raise ParseError, "E0008"
640
+ end
641
+
642
+ ps.skip_to_peek
643
+ args = get_call_arguments(ps)
644
+ result = AST::FunctionReference.new(id, args)
645
+ add_span_if_enabled(result, ps, start_pos)
646
+ return result
647
+ end
648
+
649
+ attr = nil
650
+ if ps.current_char == "."
651
+ ps.next
652
+ attr = get_identifier(ps)
653
+ end
654
+
655
+ result = AST::MessageReference.new(id, attr)
656
+ add_span_if_enabled(result, ps, start_pos)
657
+ return result
658
+ end
659
+
660
+ raise ParseError, "E0028"
661
+ end
662
+
663
+ private def get_call_argument(ps)
664
+ start_pos = ps.index if @with_spans
665
+
666
+ exp = get_inline_expression(ps)
667
+ ps.skip_blank
668
+
669
+ if ps.current_char != ":"
670
+ return exp
671
+ end
672
+
673
+ if exp.is_a?(AST::MessageReference) && exp.attribute.nil?
674
+ ps.next
675
+ ps.skip_blank
676
+
677
+ value = get_literal(ps)
678
+ result = AST::NamedArgument.new(exp.id, value)
679
+ add_span_if_enabled(result, ps, start_pos)
680
+ return result
681
+ end
682
+
683
+ raise ParseError, "E0009"
684
+ end
685
+
686
+ private def get_call_arguments(ps)
687
+ start_pos = ps.index if @with_spans
688
+
689
+ positional = []
690
+ named = []
691
+ argument_names = Set.new
692
+
693
+ ps.expect_char("(")
694
+ ps.skip_blank
695
+
696
+ loop do
697
+ break if ps.current_char == ")"
698
+
699
+ arg = get_call_argument(ps)
700
+ if arg.is_a?(AST::NamedArgument)
701
+ if argument_names.include?(arg.name.name)
702
+ raise ParseError, "E0022"
703
+ end
704
+
705
+ named << arg
706
+ argument_names.add(arg.name.name)
707
+ elsif !argument_names.empty?
708
+ raise ParseError, "E0021"
709
+ else
710
+ positional << arg
711
+ end
712
+
713
+ ps.skip_blank
714
+
715
+ if ps.current_char == ","
716
+ ps.next
717
+ ps.skip_blank
718
+ next
719
+ end
720
+
721
+ break
722
+ end
723
+
724
+ ps.expect_char(")")
725
+ result = AST::CallArguments.new(positional, named)
726
+ add_span_if_enabled(result, ps, start_pos)
727
+ result
728
+ end
729
+
730
+ private def get_string(ps)
731
+ start_pos = ps.index if @with_spans
732
+
733
+ ps.expect_char('"')
734
+ value = ""
735
+
736
+ while (ch = ps.take_char {|x| x != '"' && x != Stream::EOL })
737
+ value += ch == "\\" ? get_escape_sequence(ps) : ch
738
+ end
739
+
740
+ if ps.current_char == Stream::EOL
741
+ raise ParseError, "E0020"
742
+ end
743
+
744
+ ps.expect_char('"')
745
+
746
+ result = AST::StringLiteral.new(value)
747
+ add_span_if_enabled(result, ps, start_pos)
748
+ result
749
+ end
750
+
751
+ private def get_escape_sequence(ps)
752
+ next_char = ps.current_char
753
+
754
+ case next_char
755
+ when "\\", '"'
756
+ ps.next
757
+ "\\#{next_char}"
758
+ when "u"
759
+ get_unicode_escape_sequence(ps, next_char, 4)
760
+ when "U"
761
+ get_unicode_escape_sequence(ps, next_char, 6)
762
+ else
763
+ raise ParseError.new("E0025", next_char)
764
+ end
765
+ end
766
+
767
+ private def get_unicode_escape_sequence(ps, unicode_marker, digits)
768
+ ps.expect_char(unicode_marker)
769
+
770
+ sequence = ""
771
+ digits.times do
772
+ ch = ps.take_hex_digit
773
+
774
+ unless ch
775
+ raise ParseError.new("E0026", "\\#{unicode_marker}#{sequence}#{ps.current_char}")
776
+ end
777
+
778
+ sequence += ch
779
+ end
780
+
781
+ "\\#{unicode_marker}#{sequence}"
782
+ end
783
+
784
+ private def get_literal(ps)
785
+ if ps.number_start?
786
+ return get_number(ps)
787
+ end
788
+
789
+ if ps.current_char == '"'
790
+ return get_string(ps)
791
+ end
792
+
793
+ raise ParseError, "E0014"
794
+ end
795
+ end
796
+ end
797
+ end