fusion-lang 0.0.1.alpha1 → 0.0.1
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 +4 -4
- data/README.md +19 -6
- data/Rakefile +9 -0
- data/docs/lang/design.md +418 -28
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -57
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +62 -23
- data/docs/user/reference.md +596 -168
- data/docs/user/tutorial.md +32 -29
- data/examples/double.fsn +1 -1
- data/examples/ends.fsn +4 -0
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +17 -44
- data/lib/fusion/ast.rb +97 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +84 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +212 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +78 -0
- data/lib/fusion/cli/serializer.rb +70 -0
- data/lib/fusion/cli.rb +207 -0
- data/lib/fusion/interpreter/builtins.rb +465 -0
- data/lib/fusion/interpreter/env.rb +89 -0
- data/lib/fusion/interpreter/error_val.rb +71 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +752 -0
- data/lib/fusion/lexer.rb +249 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +542 -0
- data/lib/fusion/token.rb +22 -0
- data/lib/fusion/typed_data.rb +23 -0
- data/lib/fusion/version.rb +1 -1
- data/lib/fusion/wire_pair.rb +11 -0
- data/lib/fusion.rb +11 -1122
- data/stdlib/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +6 -0
- data/stdlib/concat.fsn +5 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/gt.fsn +9 -0
- data/stdlib/gte.fsn +9 -0
- data/stdlib/lt.fsn +9 -0
- data/stdlib/lte.fsn +9 -0
- data/stdlib/map.fsn +6 -2
- data/stdlib/range.fsn +2 -1
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +12 -0
- data/stdlib/truthy.fsn +7 -0
- metadata +41 -2
- data/stdlib/math/square.fsn +0 -1
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Transformation ===
|
|
4
|
+
#
|
|
5
|
+
# Recursive descent parser following the EBNF
|
|
6
|
+
#
|
|
7
|
+
# Input: Array<Token>
|
|
8
|
+
# Output: AST::Expression
|
|
9
|
+
|
|
10
|
+
require_relative "token"
|
|
11
|
+
require_relative "ast"
|
|
12
|
+
require_relative "interpreter/error_val"
|
|
13
|
+
|
|
14
|
+
module Fusion
|
|
15
|
+
class Parser
|
|
16
|
+
include AST
|
|
17
|
+
|
|
18
|
+
def initialize(tokens)
|
|
19
|
+
@toks = tokens
|
|
20
|
+
@i = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse a complete program. The lexer and parser report failures by raising
|
|
24
|
+
# ParseError; this single entry point rescues them and returns a standardized
|
|
25
|
+
# syntax_error value, so no caller ever sees a raw Ruby error. `site` is the
|
|
26
|
+
# syntax_error's `{origin:, file:}` context.
|
|
27
|
+
def self.parse_file(src, site:)
|
|
28
|
+
toks = Lexer.new(src).tokens
|
|
29
|
+
p = new(toks)
|
|
30
|
+
expr = p.parse_expr
|
|
31
|
+
p.expect(:eof)
|
|
32
|
+
expr
|
|
33
|
+
rescue ParseError => err
|
|
34
|
+
Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parse one REPL entry — a statement (`identifier "=" expr`) or a bare
|
|
38
|
+
# expression — returning an AST::Statement::Assignment / AST::Expression, or, like
|
|
39
|
+
# parse_file, a standardized syntax_error value instead of ever raising. The
|
|
40
|
+
# REPL uses the error/non-error distinction to tell "keep editing" (didn't
|
|
41
|
+
# parse yet) from "evaluate now" (a complete statement or expression).
|
|
42
|
+
def self.parse_repl(src, site:)
|
|
43
|
+
toks = Lexer.new(src).tokens
|
|
44
|
+
p = new(toks)
|
|
45
|
+
entry = p.parse_repl_entry
|
|
46
|
+
p.expect(:eof)
|
|
47
|
+
entry
|
|
48
|
+
rescue ParseError => err
|
|
49
|
+
Interpreter::ErrorVal.from_runtime(kind: "syntax_error", **site, operation: "parsing code", input: src, message: err.message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A leading `identifier =` marks a statement; anything else is an expression.
|
|
53
|
+
# (A bare identifier is itself a valid expression, so the `=` is the decider.)
|
|
54
|
+
def parse_repl_entry
|
|
55
|
+
if at?(:ident) && peek(1)&.type == :equals
|
|
56
|
+
parse_statement
|
|
57
|
+
else
|
|
58
|
+
parse_expr
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# statement = identifier "=" expr (REPL only; files contain one expr)
|
|
63
|
+
def parse_statement
|
|
64
|
+
name = expect(:ident).value
|
|
65
|
+
expect(:equals)
|
|
66
|
+
AST::Statement::Assignment.new(name: name, expression: parse_expr)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_expr
|
|
70
|
+
parse_or
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- Operator sugar (reference §2.7) --------------------------------------
|
|
74
|
+
# The ladder below desugars every operator to a pipe into an `@OP.*` member
|
|
75
|
+
# (or, for the map-pipes, a stdlib call). Tightest to loosest:
|
|
76
|
+
# postfix · unary (! - / ~) · pipe (| |: |? |+) · * / % // · + - · ?? · == · && · ||
|
|
77
|
+
# `@OP.*` is a shadowable `:name` reference, so a local `@OP` reskins the operators.
|
|
78
|
+
|
|
79
|
+
def parse_or
|
|
80
|
+
operands = [parse_and]
|
|
81
|
+
while at?(:oror)
|
|
82
|
+
advance
|
|
83
|
+
operands << parse_and
|
|
84
|
+
end
|
|
85
|
+
fold_operator(operands, "or")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_and
|
|
89
|
+
operands = [parse_equality]
|
|
90
|
+
while at?(:andand)
|
|
91
|
+
advance
|
|
92
|
+
operands << parse_equality
|
|
93
|
+
end
|
|
94
|
+
fold_operator(operands, "and")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_equality
|
|
98
|
+
operands = [parse_ordering]
|
|
99
|
+
while at?(:eqeq)
|
|
100
|
+
advance
|
|
101
|
+
operands << parse_ordering
|
|
102
|
+
end
|
|
103
|
+
fold_operator(operands, "equal")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `??` (compare) is binary and left-associative — it does not fold.
|
|
107
|
+
def parse_ordering
|
|
108
|
+
node = parse_additive
|
|
109
|
+
while at?(:qq)
|
|
110
|
+
advance
|
|
111
|
+
node = pipe_operator([node, parse_additive], "compare")
|
|
112
|
+
end
|
|
113
|
+
node
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A run of `+`/`-` folds into one `@OP.sum`; each `-` term is negated.
|
|
117
|
+
def parse_additive
|
|
118
|
+
terms = [parse_multiplicative]
|
|
119
|
+
folded = false
|
|
120
|
+
while at?(:plus) || at?(:minus)
|
|
121
|
+
negated = at?(:minus)
|
|
122
|
+
advance
|
|
123
|
+
term = parse_multiplicative
|
|
124
|
+
terms << (negated ? negate(term) : term)
|
|
125
|
+
folded = true
|
|
126
|
+
end
|
|
127
|
+
folded ? pipe_operator(terms, "sum") : terms.first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A run of `*`/`/` folds into one `@OP.product` (each `/` term inverted); `%`/`//`
|
|
131
|
+
# are binary and break the run. Standard left-to-right: `a * b % c` is `(a * b) % c`.
|
|
132
|
+
def parse_multiplicative
|
|
133
|
+
node = parse_pipe
|
|
134
|
+
run = nil # accumulating product-run terms, or nil
|
|
135
|
+
loop do
|
|
136
|
+
if at?(:star) || at?(:slash)
|
|
137
|
+
inverted = at?(:slash)
|
|
138
|
+
advance
|
|
139
|
+
term = parse_pipe
|
|
140
|
+
term = pipe_into(term, "invert") if inverted
|
|
141
|
+
if run
|
|
142
|
+
run << term
|
|
143
|
+
else
|
|
144
|
+
run = [node, term]
|
|
145
|
+
end
|
|
146
|
+
elsif at?(:percent) || at?(:slashslash)
|
|
147
|
+
node = pipe_operator(run, "product") if run
|
|
148
|
+
run = nil
|
|
149
|
+
op = at?(:percent) ? "modulo" : "quotient"
|
|
150
|
+
advance
|
|
151
|
+
node = pipe_operator([node, parse_pipe], op)
|
|
152
|
+
else
|
|
153
|
+
break
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
run ? pipe_operator(run, "product") : node
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def parse_pipe
|
|
160
|
+
node = parse_unary
|
|
161
|
+
loop do
|
|
162
|
+
if at?(:pipe)
|
|
163
|
+
advance
|
|
164
|
+
node = Expression::Pipe.new(left: node, right: parse_unary)
|
|
165
|
+
elsif at?(:pipemap) || at?(:pipefilter) || at?(:pipereduce)
|
|
166
|
+
target = { pipemap: "map", pipefilter: "filter", pipereduce: "reduce" }[peek.type]
|
|
167
|
+
advance
|
|
168
|
+
arg = Expression::ObjLit.new(pairs: [
|
|
169
|
+
KeyValuePair.new(key: "c", value: node),
|
|
170
|
+
KeyValuePair.new(key: "f", value: parse_unary),
|
|
171
|
+
])
|
|
172
|
+
node = Expression::Pipe.new(left: arg, right: name_ref(target))
|
|
173
|
+
else
|
|
174
|
+
break
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
node
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Tokens that can begin a unary operand — used to decide whether `!` has an
|
|
181
|
+
# operand or is the bare `!null`.
|
|
182
|
+
PRIMARY_STARTERS = %i[number string true_kw false_kw null_kw bang minus slash tilde
|
|
183
|
+
lbracket lbrace lparen ident at atat].freeze
|
|
184
|
+
|
|
185
|
+
# Unary prefixes: `!x` builds an error (bare `!` is `!null`); `-x` negates,
|
|
186
|
+
# `/x` inverts, `~x` is logical not. All bind tighter than `|`.
|
|
187
|
+
def parse_unary
|
|
188
|
+
if at?(:bang)
|
|
189
|
+
advance
|
|
190
|
+
PRIMARY_STARTERS.include?(peek.type) ? Expression::ErrLit.new(payload: parse_unary) : Expression::ErrLit.new(payload: nil)
|
|
191
|
+
elsif at?(:minus)
|
|
192
|
+
advance
|
|
193
|
+
negate(parse_unary)
|
|
194
|
+
elsif at?(:slash)
|
|
195
|
+
advance
|
|
196
|
+
pipe_into(parse_unary, "invert")
|
|
197
|
+
elsif at?(:tilde)
|
|
198
|
+
advance
|
|
199
|
+
pipe_into(parse_unary, "not")
|
|
200
|
+
else
|
|
201
|
+
parse_postfix
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_postfix
|
|
206
|
+
node = parse_primary
|
|
207
|
+
loop do
|
|
208
|
+
if at?(:dot)
|
|
209
|
+
advance
|
|
210
|
+
key = expect(:ident).value
|
|
211
|
+
node = Expression::Member.new(obj: node, key: key)
|
|
212
|
+
elsif at?(:lbracket)
|
|
213
|
+
advance
|
|
214
|
+
idx = parse_expr
|
|
215
|
+
if at?(:equals)
|
|
216
|
+
advance
|
|
217
|
+
value = parse_expr
|
|
218
|
+
expect(:rbracket)
|
|
219
|
+
node = Expression::IndexSet.new(obj: node, idx: idx, value: value)
|
|
220
|
+
else
|
|
221
|
+
expect(:rbracket)
|
|
222
|
+
node = Expression::Index.new(obj: node, idx: idx)
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
break
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
node
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# --- Desugaring helpers ---------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def name_ref(path) = Expression::FileRef.new(variety: :name, path: path)
|
|
234
|
+
|
|
235
|
+
# `@OP.member`, a shadowable reference.
|
|
236
|
+
def op_member(member) = Expression::Member.new(obj: name_ref("OP"), key: member)
|
|
237
|
+
|
|
238
|
+
# `expr | @OP.member`
|
|
239
|
+
def pipe_into(expr, member) = Expression::Pipe.new(left: expr, right: op_member(member))
|
|
240
|
+
|
|
241
|
+
# `[operands...] | @OP.member`
|
|
242
|
+
def pipe_operator(operands, member)
|
|
243
|
+
arr = Expression::ArrLit.new(items: operands.map { |e| ArrayItem.new(value: e) })
|
|
244
|
+
Expression::Pipe.new(left: arr, right: op_member(member))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A single-operand run collapses to that operand; otherwise fold n-ary.
|
|
248
|
+
def fold_operator(operands, member)
|
|
249
|
+
operands.length == 1 ? operands.first : pipe_operator(operands, member)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# `-x`: a numeric literal folds to a negative literal; anything else negates.
|
|
253
|
+
def negate(expr)
|
|
254
|
+
if expr.is_a?(Expression::Lit) && expr.value.is_a?(Numeric)
|
|
255
|
+
Expression::Lit.new(value: -expr.value)
|
|
256
|
+
else
|
|
257
|
+
pipe_into(expr, "negate")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def parse_primary
|
|
262
|
+
t = peek
|
|
263
|
+
case t.type
|
|
264
|
+
when :number, :string then advance; Expression::Lit.new(value: t.value)
|
|
265
|
+
when :true_kw, :false_kw, :null_kw then advance; Expression::Lit.new(value: t.value)
|
|
266
|
+
when :lbracket then parse_array
|
|
267
|
+
when :lbrace then parse_object
|
|
268
|
+
when :lparen then parse_function_or_group
|
|
269
|
+
when :ident then advance; Expression::Ident.new(name: t.value)
|
|
270
|
+
when :at then parse_fileref
|
|
271
|
+
when :atat then parse_superref
|
|
272
|
+
else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# `@@` is super. Bare `@@` is super of the current file's own name; `@@name`
|
|
277
|
+
# (or a downward `@@dir/name`) is a *stable* reference to that name — it skips
|
|
278
|
+
# the sibling `name.fsn`, resolving builtin → stdlib, so a user's local shadow
|
|
279
|
+
# can't intercept it. `@@../…` is meaningless (super of an upward path) and
|
|
280
|
+
# falls through to a parse error.
|
|
281
|
+
def parse_superref
|
|
282
|
+
expect(:atat)
|
|
283
|
+
return Expression::FileRef.new(variety: :super, path: nil) unless at?(:path)
|
|
284
|
+
|
|
285
|
+
tok = advance
|
|
286
|
+
raise ParseError, "`@@` cannot take an upward path (at #{tok.pos})" if tok.value.include?("..")
|
|
287
|
+
Expression::FileRef.new(variety: :super_name, path: tok.value)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def parse_fileref
|
|
291
|
+
expect(:at)
|
|
292
|
+
return Expression::FileRef.new(variety: :self, path: nil) unless at?(:path)
|
|
293
|
+
|
|
294
|
+
# A reference is eligible for builtin/stdlib fallback (:name) iff it has no
|
|
295
|
+
# "../"; downward paths stay eligible, only "../" forces file-only (:path).
|
|
296
|
+
# The lexer produced the whole path as one tight token (see Lexer#try_lex_path).
|
|
297
|
+
path = advance.value
|
|
298
|
+
Expression::FileRef.new(variety: path.include?("..") ? :path : :name, path: path)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def parse_array
|
|
302
|
+
expect(:lbracket)
|
|
303
|
+
items = []
|
|
304
|
+
until at?(:rbracket)
|
|
305
|
+
if at?(:spread)
|
|
306
|
+
advance
|
|
307
|
+
items << ArraySpread.new(value: parse_expr)
|
|
308
|
+
else
|
|
309
|
+
items << ArrayItem.new(value: parse_expr)
|
|
310
|
+
end
|
|
311
|
+
break unless at?(:comma)
|
|
312
|
+
advance
|
|
313
|
+
end
|
|
314
|
+
expect(:rbracket)
|
|
315
|
+
Expression::ArrLit.new(items: items)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Fixed keys must be distinct (the ObjLit data rule); a repeat is a clean
|
|
319
|
+
# syntax_error. Keys arriving via `...spread` are dynamic and not checked.
|
|
320
|
+
def parse_object
|
|
321
|
+
expect(:lbrace)
|
|
322
|
+
pairs = []
|
|
323
|
+
keys = []
|
|
324
|
+
until at?(:rbrace)
|
|
325
|
+
if at?(:spread)
|
|
326
|
+
advance
|
|
327
|
+
pairs << ObjectSpread.new(value: parse_expr)
|
|
328
|
+
else
|
|
329
|
+
key_tok = expect(:string)
|
|
330
|
+
key = key_tok.value
|
|
331
|
+
raise ParseError, "duplicate key #{key.inspect} (at #{key_tok.pos})" if keys.include?(key)
|
|
332
|
+
keys << key
|
|
333
|
+
expect(:colon)
|
|
334
|
+
pairs << KeyValuePair.new(key: key, value: parse_expr)
|
|
335
|
+
end
|
|
336
|
+
break unless at?(:comma)
|
|
337
|
+
advance
|
|
338
|
+
end
|
|
339
|
+
expect(:rbrace)
|
|
340
|
+
Expression::ObjLit.new(pairs: pairs)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A "(" begins a grouped expression, a function literal, or — when empty —
|
|
344
|
+
# the clause-less function `()`. A function is a comma-separated list of
|
|
345
|
+
# `pattern => expr`; we detect one by scanning for a top-level `=>` before the
|
|
346
|
+
# matching `)`. `()` matches nothing (so it yields null for any normal input
|
|
347
|
+
# and propagates errors).
|
|
348
|
+
def parse_function_or_group
|
|
349
|
+
expect(:lparen)
|
|
350
|
+
if at?(:rparen)
|
|
351
|
+
advance
|
|
352
|
+
return Expression::FuncLit.new(clauses: [])
|
|
353
|
+
end
|
|
354
|
+
if looks_like_function?
|
|
355
|
+
clauses = []
|
|
356
|
+
loop do
|
|
357
|
+
pat = parse_pattern
|
|
358
|
+
expect(:arrow)
|
|
359
|
+
body = parse_expr
|
|
360
|
+
clauses << Clause.new(pattern: pat, body: body)
|
|
361
|
+
if at?(:comma)
|
|
362
|
+
advance
|
|
363
|
+
break if at?(:rparen) # trailing comma
|
|
364
|
+
else
|
|
365
|
+
break
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
expect(:rparen)
|
|
369
|
+
Expression::FuncLit.new(clauses: clauses)
|
|
370
|
+
else
|
|
371
|
+
e = parse_expr
|
|
372
|
+
expect(:rparen)
|
|
373
|
+
e
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Look ahead from current position (just after "(") to decide if this is a
|
|
378
|
+
# function literal: is there a top-level "=>" before the matching ")"?
|
|
379
|
+
def looks_like_function?
|
|
380
|
+
depth = 0
|
|
381
|
+
j = @i
|
|
382
|
+
while j < @toks.length
|
|
383
|
+
t = @toks[j]
|
|
384
|
+
case t.type
|
|
385
|
+
when :lparen, :lbracket, :lbrace then depth += 1
|
|
386
|
+
when :rparen, :rbracket, :rbrace
|
|
387
|
+
return false if depth.zero? # hit our closing ) first
|
|
388
|
+
depth -= 1
|
|
389
|
+
when :arrow
|
|
390
|
+
return true if depth.zero?
|
|
391
|
+
when :eof
|
|
392
|
+
return false
|
|
393
|
+
end
|
|
394
|
+
j += 1
|
|
395
|
+
end
|
|
396
|
+
false
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# ---- Patterns ----
|
|
400
|
+
# ---- Pattern grammar (mirrors reference.md §2.5 EBNF) ------------------
|
|
401
|
+
# pattern = p_error | p_guarded
|
|
402
|
+
# p_error = "!" | "!" p_guarded
|
|
403
|
+
# p_guarded = p_core [ "?" predicate ]
|
|
404
|
+
# p_core = p_literal | p_bind | p_wildcard | p_array | p_object
|
|
405
|
+
# Note: `p_core` does NOT include p_error. The "no nested !pat" property
|
|
406
|
+
# falls out of the grammar shape — `p_error` is only reachable from `pattern`
|
|
407
|
+
# (a clause's top level), never from inside arrays, objects, or another
|
|
408
|
+
# error's payload. No flag-threading is needed.
|
|
409
|
+
def parse_pattern
|
|
410
|
+
at?(:bang) ? parse_errpat : parse_guardedpat
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Tokens that can begin a `guardedpat` (used to detect whether `!` is
|
|
414
|
+
# followed by a payload pattern or stands alone).
|
|
415
|
+
GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw minus
|
|
416
|
+
lbracket lbrace ident].freeze
|
|
417
|
+
|
|
418
|
+
def parse_errpat
|
|
419
|
+
expect(:bang)
|
|
420
|
+
if GUARDEDPAT_STARTERS.include?(peek.type)
|
|
421
|
+
Pattern::PErr.new(inner: parse_guardedpat) # "!" guardedpat
|
|
422
|
+
else
|
|
423
|
+
Pattern::PErr.new(inner: Pattern::PWild.new(dummy: nil)) # bare "!" — matches any error, binds nothing
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def parse_guardedpat
|
|
428
|
+
inner = parse_corepat
|
|
429
|
+
if at?(:question)
|
|
430
|
+
advance
|
|
431
|
+
# A predicate is a full pipe so it may chain functions: `a ? b | c` tests
|
|
432
|
+
# `a | b | c`. It stops at `=>`, `,`, `]`, `}`, `)` like any expression.
|
|
433
|
+
pred = parse_pipe
|
|
434
|
+
Pattern::PGuard.new(inner: inner, pred_expr: pred)
|
|
435
|
+
else
|
|
436
|
+
inner
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def parse_corepat
|
|
441
|
+
t = peek
|
|
442
|
+
case t.type
|
|
443
|
+
when :number, :string then advance; Pattern::PLit.new(value: t.value)
|
|
444
|
+
when :true_kw, :false_kw, :null_kw then advance; Pattern::PLit.new(value: t.value)
|
|
445
|
+
when :minus then advance; Pattern::PLit.new(value: -expect(:number).value) # negative literal
|
|
446
|
+
when :lbracket then parse_arraypat
|
|
447
|
+
when :lbrace then parse_objectpat
|
|
448
|
+
when :ident
|
|
449
|
+
advance
|
|
450
|
+
t.value == "_" ? Pattern::PWild.new(dummy: nil) : Pattern::PBind.new(name: t.value)
|
|
451
|
+
when :bang
|
|
452
|
+
# `!pat` is only valid as a clause's top-level pattern, never inside an
|
|
453
|
+
# array element, object member, or error payload.
|
|
454
|
+
raise ParseError, "`!pat` may only appear as a clause's top-level pattern (at #{t.pos})"
|
|
455
|
+
else
|
|
456
|
+
raise ParseError, "Unexpected token in pattern: #{t.type} at #{t.pos}"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# p_array (reference.md §2.5). Items are `p_guarded`s — never error patterns.
|
|
461
|
+
# The grammar's two arms (with / without a rest) become two phases: the loop
|
|
462
|
+
# parses leading items up to an optional single `...rest`; once a rest is
|
|
463
|
+
# consumed, the inner loop parses trailing items only, so a second `...` lands
|
|
464
|
+
# in `parse_guardedpat` as an unexpected token. There is no `seen_rest` flag —
|
|
465
|
+
# "at most one rest" is enforced by the shape of the loop.
|
|
466
|
+
def parse_arraypat
|
|
467
|
+
expect(:lbracket)
|
|
468
|
+
items = []
|
|
469
|
+
until at?(:rbracket)
|
|
470
|
+
if at?(:spread)
|
|
471
|
+
items << parse_pattern_rest
|
|
472
|
+
while at?(:comma)
|
|
473
|
+
advance
|
|
474
|
+
break if at?(:rbracket) # trailing comma
|
|
475
|
+
raise ParseError, "a pattern may contain at most one `...rest` (at #{peek.pos})" if at?(:spread)
|
|
476
|
+
items << PatternItem.new(pattern: parse_guardedpat)
|
|
477
|
+
end
|
|
478
|
+
break
|
|
479
|
+
end
|
|
480
|
+
items << PatternItem.new(pattern: parse_guardedpat)
|
|
481
|
+
break unless at?(:comma)
|
|
482
|
+
advance
|
|
483
|
+
end
|
|
484
|
+
expect(:rbracket)
|
|
485
|
+
Pattern::PArr.new(items: items)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# p_object (reference.md §2.5). Leading pairs up to an optional single
|
|
489
|
+
# `...rest`, which must come last — only a trailing comma may follow it. Keys
|
|
490
|
+
# must be distinct (the PObj data rule); a repeat is a clean syntax_error.
|
|
491
|
+
def parse_objectpat
|
|
492
|
+
expect(:lbrace)
|
|
493
|
+
pairs = []
|
|
494
|
+
keys = []
|
|
495
|
+
until at?(:rbrace)
|
|
496
|
+
if at?(:spread)
|
|
497
|
+
pairs << parse_pattern_rest
|
|
498
|
+
advance if at?(:comma) && peek(1)&.type == :rbrace # trailing comma
|
|
499
|
+
unless at?(:rbrace)
|
|
500
|
+
raise ParseError, "in an object pattern, `...rest` must come last (at #{peek.pos})"
|
|
501
|
+
end
|
|
502
|
+
break
|
|
503
|
+
end
|
|
504
|
+
key_pos = peek.pos
|
|
505
|
+
pair = parse_pattern_pair
|
|
506
|
+
raise ParseError, "duplicate key #{pair.key.inspect} (at #{key_pos})" if keys.include?(pair.key)
|
|
507
|
+
keys << pair.key
|
|
508
|
+
pairs << pair
|
|
509
|
+
break unless at?(:comma)
|
|
510
|
+
advance
|
|
511
|
+
end
|
|
512
|
+
expect(:rbrace)
|
|
513
|
+
Pattern::PObj.new(pairs: pairs)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# p_rest = "..." [ identifier ] — the single rest binder, shared by array and
|
|
517
|
+
# object patterns. Callers parse it only at a rest position and then continue
|
|
518
|
+
# with items/pairs only, which is what holds a pattern to one rest.
|
|
519
|
+
def parse_pattern_rest
|
|
520
|
+
expect(:spread)
|
|
521
|
+
name = at?(:ident) ? advance.value : nil
|
|
522
|
+
PatternRest.new(name: name)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# p_pair = string ":" p_guarded
|
|
526
|
+
def parse_pattern_pair
|
|
527
|
+
key = expect(:string).value
|
|
528
|
+
expect(:colon)
|
|
529
|
+
PatternPair.new(key: key, pattern: parse_guardedpat)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# ---- token helpers ----
|
|
533
|
+
def peek(o = 0) = @toks[@i + o]
|
|
534
|
+
def at?(type) = peek.type == type
|
|
535
|
+
def advance = (@toks[@i].tap { @i += 1 })
|
|
536
|
+
def expect(type)
|
|
537
|
+
t = peek
|
|
538
|
+
raise ParseError, "Expected #{type} but got #{t.type} (#{t.value.inspect}) at #{t.pos}" unless t.type == type
|
|
539
|
+
advance
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
data/lib/fusion/token.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Data Structure ===
|
|
4
|
+
#
|
|
5
|
+
# Array<Token> is
|
|
6
|
+
# - output of the lexer
|
|
7
|
+
# - input of the parser
|
|
8
|
+
|
|
9
|
+
require_relative "typed_data"
|
|
10
|
+
require_relative "atom"
|
|
11
|
+
|
|
12
|
+
module Fusion
|
|
13
|
+
# type: one of the lexer's token-type symbols (:number, :ident, :lparen, ...).
|
|
14
|
+
# value: the token's payload — a scalar for literals/keywords, the matched
|
|
15
|
+
# text for punctuation/identifiers, or nil for :eof.
|
|
16
|
+
# pos: the token's source offset.
|
|
17
|
+
Token = TypedData.define(
|
|
18
|
+
type: Symbol,
|
|
19
|
+
value: ->(v) { Atom === v || v.nil? },
|
|
20
|
+
pos: Integer,
|
|
21
|
+
)
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Utility ===
|
|
4
|
+
#
|
|
5
|
+
# Ruby's `Data` with `===` type per attribute. A type is anything `===`-able:
|
|
6
|
+
# a class (`Integer`), a regexp (`Identifier`), a marker module, or — for
|
|
7
|
+
# anything composite (unions, typed arrays, enums, "optional") — a `proc`.
|
|
8
|
+
# Mini-clone of gem `literal`.
|
|
9
|
+
|
|
10
|
+
module TypedData
|
|
11
|
+
def self.define(**schema)
|
|
12
|
+
Data.define(*schema.keys) do
|
|
13
|
+
define_method(:initialize) do |**kwargs|
|
|
14
|
+
kwargs.each_key do |key|
|
|
15
|
+
unless schema[key] === kwargs[key]
|
|
16
|
+
raise TypeError, "#{key}: expected #{schema[key]}, got #{kwargs[key].inspect} (#{kwargs[key].class})"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
super(**kwargs)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/fusion/version.rb
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Value ===
|
|
4
|
+
#
|
|
5
|
+
# The combination of status code and value (JSON string)
|
|
6
|
+
|
|
7
|
+
require_relative "typed_data"
|
|
8
|
+
|
|
9
|
+
module Fusion
|
|
10
|
+
WirePair = TypedData.define(status: ->(v) { Integer === v && [0, 1].include?(v) }, data: String)
|
|
11
|
+
end
|