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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/exe/foxtail +12 -0
- data/lib/foxtail/cli/commands/check.rb +60 -0
- data/lib/foxtail/cli/commands/dump.rb +43 -0
- data/lib/foxtail/cli/commands/ids.rb +73 -0
- data/lib/foxtail/cli/commands/tidy.rb +107 -0
- data/lib/foxtail/cli.rb +59 -0
- data/lib/foxtail/syntax/error.rb +8 -0
- data/lib/foxtail/syntax/parser/ast/annotation.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/attribute.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/base_comment.rb +19 -0
- data/lib/foxtail/syntax/parser/ast/base_literal.rb +24 -0
- data/lib/foxtail/syntax/parser/ast/base_node.rb +89 -0
- data/lib/foxtail/syntax/parser/ast/call_arguments.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/comment.rb +13 -0
- data/lib/foxtail/syntax/parser/ast/function_reference.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/group_comment.rb +13 -0
- data/lib/foxtail/syntax/parser/ast/identifier.rb +19 -0
- data/lib/foxtail/syntax/parser/ast/junk.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/message.rb +28 -0
- data/lib/foxtail/syntax/parser/ast/message_reference.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/named_argument.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/number_literal.rb +24 -0
- data/lib/foxtail/syntax/parser/ast/pattern.rb +22 -0
- data/lib/foxtail/syntax/parser/ast/placeable.rb +21 -0
- data/lib/foxtail/syntax/parser/ast/resource.rb +55 -0
- data/lib/foxtail/syntax/parser/ast/resource_comment.rb +13 -0
- data/lib/foxtail/syntax/parser/ast/select_expression.rb +23 -0
- data/lib/foxtail/syntax/parser/ast/span.rb +22 -0
- data/lib/foxtail/syntax/parser/ast/string_literal.rb +45 -0
- data/lib/foxtail/syntax/parser/ast/syntax_node.rb +22 -0
- data/lib/foxtail/syntax/parser/ast/term.rb +28 -0
- data/lib/foxtail/syntax/parser/ast/term_reference.rb +25 -0
- data/lib/foxtail/syntax/parser/ast/text_element.rb +19 -0
- data/lib/foxtail/syntax/parser/ast/variable_reference.rb +21 -0
- data/lib/foxtail/syntax/parser/ast/variant.rb +25 -0
- data/lib/foxtail/syntax/parser/ast.rb +12 -0
- data/lib/foxtail/syntax/parser/parse_error.rb +94 -0
- data/lib/foxtail/syntax/parser/stream.rb +338 -0
- data/lib/foxtail/syntax/parser.rb +797 -0
- data/lib/foxtail/syntax/serializer.rb +242 -0
- data/lib/foxtail/syntax/visitor.rb +61 -0
- data/lib/foxtail/syntax.rb +12 -0
- data/lib/foxtail/tools/error.rb +8 -0
- data/lib/foxtail/tools/version.rb +9 -0
- data/lib/foxtail-tools.rb +22 -0
- 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
|