fusion-lang 0.0.1.alpha1
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/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/Rakefile +8 -0
- data/docs/index.md +34 -0
- data/docs/lang/design.md +674 -0
- data/docs/lang/roadmap.md +97 -0
- data/docs/user/explanation.md +157 -0
- data/docs/user/how-to-guides.md +205 -0
- data/docs/user/reference.md +505 -0
- data/docs/user/tutorial.md +338 -0
- data/examples/double.fsn +4 -0
- data/examples/factorial.fsn +6 -0
- data/examples/first.fsn +4 -0
- data/examples/fizzbuzz.fsn +15 -0
- data/examples/palindrome.fsn +8 -0
- data/exe/fusion +57 -0
- data/lib/fusion/version.rb +3 -0
- data/lib/fusion.rb +1140 -0
- data/stdlib/map.fsn +4 -0
- data/stdlib/math/square.fsn +1 -0
- data/stdlib/range.fsn +4 -0
- metadata +67 -0
data/lib/fusion.rb
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Fusion — a proof-of-concept interpreter.
|
|
5
|
+
#
|
|
6
|
+
# This file is the interpreter library; the CLI entrypoint lives in exe/fusion.
|
|
7
|
+
#
|
|
8
|
+
# A file contains exactly one value. A file is "executable" if that value is a
|
|
9
|
+
# function; the runtime computes STDIN | thatFunction and prints the result.
|
|
10
|
+
#
|
|
11
|
+
# Values are represented in Ruby as:
|
|
12
|
+
# null -> :null (we avoid Ruby nil so "absent" is explicit)
|
|
13
|
+
# ! -> ErrorVal (always carries a payload; bare `!` means `!null`)
|
|
14
|
+
# bool -> true / false
|
|
15
|
+
# int -> Integer
|
|
16
|
+
# float -> Float
|
|
17
|
+
# string -> String
|
|
18
|
+
# array -> Array
|
|
19
|
+
# object -> Hash (String keys, insertion-ordered as Ruby preserves)
|
|
20
|
+
# func -> Func (closure over an Env)
|
|
21
|
+
|
|
22
|
+
require_relative "fusion/version"
|
|
23
|
+
|
|
24
|
+
module Fusion
|
|
25
|
+
# ---- Special singletons -------------------------------------------------
|
|
26
|
+
NULL = :null
|
|
27
|
+
|
|
28
|
+
# An error value, always carrying a payload (any JSON-like Fusion value).
|
|
29
|
+
# `mkerr(payload)` constructs one; a bare `!` in source means `!null`.
|
|
30
|
+
class ErrorVal
|
|
31
|
+
attr_reader :payload
|
|
32
|
+
def initialize(payload) @payload = payload end
|
|
33
|
+
def inspect = "!#{payload.inspect}"
|
|
34
|
+
def to_s = inspect
|
|
35
|
+
end
|
|
36
|
+
def self.mkerr(payload = NULL) = ErrorVal.new(payload)
|
|
37
|
+
def self.error?(v) = v.is_a?(ErrorVal)
|
|
38
|
+
|
|
39
|
+
class FusionError < StandardError; end
|
|
40
|
+
class ParseError < FusionError; end
|
|
41
|
+
|
|
42
|
+
# =========================================================================
|
|
43
|
+
# LEXER
|
|
44
|
+
# =========================================================================
|
|
45
|
+
Token = Struct.new(:type, :value, :pos)
|
|
46
|
+
|
|
47
|
+
class Lexer
|
|
48
|
+
PUNCT = {
|
|
49
|
+
"(" => :lparen, ")" => :rparen,
|
|
50
|
+
"[" => :lbracket, "]" => :rbracket,
|
|
51
|
+
"{" => :lbrace, "}" => :rbrace,
|
|
52
|
+
"," => :comma, ":" => :colon,
|
|
53
|
+
"|" => :pipe, "?" => :question, "." => :dot,
|
|
54
|
+
"@" => :at, "/" => :slash,
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
def initialize(src)
|
|
58
|
+
@src = src
|
|
59
|
+
@i = 0
|
|
60
|
+
@n = src.length
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tokens
|
|
64
|
+
out = []
|
|
65
|
+
loop do
|
|
66
|
+
t = next_token
|
|
67
|
+
out << t
|
|
68
|
+
break if t.type == :eof
|
|
69
|
+
end
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def peek(o = 0) = @i + o < @n ? @src[@i + o] : nil
|
|
76
|
+
|
|
77
|
+
def next_token
|
|
78
|
+
skip_trivia
|
|
79
|
+
start = @i
|
|
80
|
+
c = peek
|
|
81
|
+
return Token.new(:eof, nil, start) if c.nil?
|
|
82
|
+
|
|
83
|
+
# "=>" and "..." handled specially ("#" line comments handled in skip_trivia)
|
|
84
|
+
if c == "=" && peek(1) == ">"
|
|
85
|
+
@i += 2
|
|
86
|
+
return Token.new(:arrow, "=>", start)
|
|
87
|
+
end
|
|
88
|
+
if c == "." && peek(1) == "." && peek(2) == "."
|
|
89
|
+
@i += 3
|
|
90
|
+
return Token.new(:spread, "...", start)
|
|
91
|
+
end
|
|
92
|
+
if c == "!"
|
|
93
|
+
@i += 1
|
|
94
|
+
return Token.new(:bang, "!", start)
|
|
95
|
+
end
|
|
96
|
+
if c == '"'
|
|
97
|
+
return lex_string(start)
|
|
98
|
+
end
|
|
99
|
+
if digit?(c) || (c == "-" && digit?(peek(1)))
|
|
100
|
+
return lex_number(start)
|
|
101
|
+
end
|
|
102
|
+
if ident_start?(c)
|
|
103
|
+
return lex_word(start)
|
|
104
|
+
end
|
|
105
|
+
if (type = PUNCT[c])
|
|
106
|
+
@i += 1
|
|
107
|
+
return Token.new(type, c, start)
|
|
108
|
+
end
|
|
109
|
+
raise ParseError, "Unexpected character #{c.inspect} at #{start}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def skip_trivia
|
|
113
|
+
loop do
|
|
114
|
+
c = peek
|
|
115
|
+
if c == " " || c == "\t" || c == "\n" || c == "\r"
|
|
116
|
+
@i += 1
|
|
117
|
+
elsif c == "#" && at_line_start?
|
|
118
|
+
# A line is a comment iff its first non-whitespace char is "#".
|
|
119
|
+
# This also covers shebang lines (#!) for free.
|
|
120
|
+
@i += 1 until peek.nil? || peek == "\n"
|
|
121
|
+
else
|
|
122
|
+
break
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# True when only whitespace precedes @i on the current physical line.
|
|
128
|
+
def at_line_start?
|
|
129
|
+
j = @i - 1
|
|
130
|
+
j -= 1 while j >= 0 && (@src[j] == " " || @src[j] == "\t")
|
|
131
|
+
j < 0 || @src[j] == "\n" || @src[j] == "\r"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def lex_string(start)
|
|
135
|
+
@i += 1 # opening quote
|
|
136
|
+
buf = +""
|
|
137
|
+
while (c = peek)
|
|
138
|
+
if c == '"'
|
|
139
|
+
@i += 1
|
|
140
|
+
return Token.new(:string, buf, start)
|
|
141
|
+
elsif c == "\\"
|
|
142
|
+
@i += 1
|
|
143
|
+
e = peek
|
|
144
|
+
buf << case e
|
|
145
|
+
when '"' then '"'
|
|
146
|
+
when "\\" then "\\"
|
|
147
|
+
when "/" then "/"
|
|
148
|
+
when "n" then "\n"
|
|
149
|
+
when "t" then "\t"
|
|
150
|
+
when "r" then "\r"
|
|
151
|
+
when "b" then "\b"
|
|
152
|
+
when "f" then "\f"
|
|
153
|
+
when "u"
|
|
154
|
+
hex = @src[@i + 1, 4]
|
|
155
|
+
@i += 4
|
|
156
|
+
[hex.to_i(16)].pack("U")
|
|
157
|
+
else
|
|
158
|
+
raise ParseError, "Bad escape \\#{e}"
|
|
159
|
+
end
|
|
160
|
+
@i += 1
|
|
161
|
+
elsif c == "\n" || c == "\r"
|
|
162
|
+
raise ParseError, "Raw newline in string starting at #{start}; use \\n"
|
|
163
|
+
else
|
|
164
|
+
buf << c
|
|
165
|
+
@i += 1
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
raise ParseError, "Unterminated string starting at #{start}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def lex_number(start)
|
|
172
|
+
j = @i
|
|
173
|
+
j += 1 if @src[j] == "-"
|
|
174
|
+
j += 1 while j < @n && digit?(@src[j])
|
|
175
|
+
is_float = false
|
|
176
|
+
if @src[j] == "." && digit?(@src[j + 1])
|
|
177
|
+
is_float = true
|
|
178
|
+
j += 1
|
|
179
|
+
j += 1 while j < @n && digit?(@src[j])
|
|
180
|
+
end
|
|
181
|
+
if (@src[j] == "e" || @src[j] == "E")
|
|
182
|
+
is_float = true
|
|
183
|
+
j += 1
|
|
184
|
+
j += 1 if (@src[j] == "+" || @src[j] == "-")
|
|
185
|
+
j += 1 while j < @n && digit?(@src[j])
|
|
186
|
+
end
|
|
187
|
+
text = @src[@i...j]
|
|
188
|
+
@i = j
|
|
189
|
+
val = is_float ? text.to_f : text.to_i
|
|
190
|
+
Token.new(:number, val, start)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def lex_word(start)
|
|
194
|
+
j = @i
|
|
195
|
+
j += 1 while j < @n && ident_part?(@src[j])
|
|
196
|
+
text = @src[@i...j]
|
|
197
|
+
@i = j
|
|
198
|
+
case text
|
|
199
|
+
when "true" then Token.new(:true_kw, true, start)
|
|
200
|
+
when "false" then Token.new(:false_kw, false, start)
|
|
201
|
+
when "null" then Token.new(:null_kw, NULL, start)
|
|
202
|
+
else Token.new(:ident, text, start)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def digit?(c) = c && c >= "0" && c <= "9"
|
|
207
|
+
def ident_start?(c) = c && (c =~ /[A-Za-z_]/)
|
|
208
|
+
def ident_part?(c) = c && (c =~ /[A-Za-z0-9_]/)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# =========================================================================
|
|
212
|
+
# AST
|
|
213
|
+
# =========================================================================
|
|
214
|
+
# Expressions
|
|
215
|
+
Lit = Struct.new(:value) # atom literal (incl NULL)
|
|
216
|
+
ErrLit = Struct.new(:payload) # !expr or bare ! (payload nil = !null)
|
|
217
|
+
ArrLit = Struct.new(:elems) # elems: [[:item|:spread, expr], ...]
|
|
218
|
+
ObjLit = Struct.new(:members) # [[:kv, key, expr] | [:spread, expr]]
|
|
219
|
+
FuncLit = Struct.new(:clauses) # [[pattern, expr], ...]
|
|
220
|
+
Ident = Struct.new(:name) # read a builtin/bound name
|
|
221
|
+
FileRef = Struct.new(:variety, :path) # variety: :self|:name|:path
|
|
222
|
+
Pipe = Struct.new(:left, :right) # left | right
|
|
223
|
+
Member = Struct.new(:obj, :key) # obj.key
|
|
224
|
+
Index = Struct.new(:obj, :idx) # obj[expr]
|
|
225
|
+
|
|
226
|
+
# Patterns
|
|
227
|
+
PLit = Struct.new(:value) # literal pattern
|
|
228
|
+
PErr = Struct.new(:inner) # ! or !pat ; inner=nil matches any error
|
|
229
|
+
PBind = Struct.new(:name) # binds
|
|
230
|
+
PWild = Struct.new(:dummy) # _
|
|
231
|
+
PArr = Struct.new(:elems) # [[:pat,p]|[:rest,name_or_nil], ...]
|
|
232
|
+
PObj = Struct.new(:members) # [[:kv,key,pat]|[:rest,name_or_nil]]
|
|
233
|
+
PGuard = Struct.new(:inner, :pred_expr) # inner ? predicate
|
|
234
|
+
|
|
235
|
+
# =========================================================================
|
|
236
|
+
# PARSER (recursive descent following the EBNF)
|
|
237
|
+
# =========================================================================
|
|
238
|
+
class Parser
|
|
239
|
+
def initialize(tokens)
|
|
240
|
+
@toks = tokens
|
|
241
|
+
@i = 0
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.parse_file(src)
|
|
245
|
+
toks = Lexer.new(src).tokens
|
|
246
|
+
p = new(toks)
|
|
247
|
+
expr = p.parse_expr
|
|
248
|
+
p.expect(:eof)
|
|
249
|
+
expr
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def parse_expr = parse_pipe
|
|
253
|
+
|
|
254
|
+
def parse_pipe
|
|
255
|
+
left = parse_prefix
|
|
256
|
+
while at?(:pipe)
|
|
257
|
+
advance
|
|
258
|
+
right = parse_prefix
|
|
259
|
+
left = Pipe.new(left, right)
|
|
260
|
+
end
|
|
261
|
+
left
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Tokens that can begin a primary expression (used by parse_prefix to decide
|
|
265
|
+
# whether `!` is followed by an operand).
|
|
266
|
+
PRIMARY_STARTERS = %i[number string true_kw false_kw null_kw bang
|
|
267
|
+
lbracket lbrace lparen ident at].freeze
|
|
268
|
+
|
|
269
|
+
# `!` is a prefix operator that constructs an error from its operand. A bare
|
|
270
|
+
# `!` (no operand follows) is shorthand for `!null`. Binds tighter than `|`
|
|
271
|
+
# so `!x | f` is `(!x) | f`; looser than postfix so `!x.foo` is `!(x.foo)`.
|
|
272
|
+
def parse_prefix
|
|
273
|
+
if at?(:bang)
|
|
274
|
+
advance
|
|
275
|
+
if PRIMARY_STARTERS.include?(peek.type)
|
|
276
|
+
ErrLit.new(parse_prefix) # allow !!x to nest
|
|
277
|
+
else
|
|
278
|
+
ErrLit.new(nil) # bare ! -> !null
|
|
279
|
+
end
|
|
280
|
+
else
|
|
281
|
+
parse_postfix
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def parse_postfix
|
|
286
|
+
node = parse_primary
|
|
287
|
+
loop do
|
|
288
|
+
if at?(:dot)
|
|
289
|
+
advance
|
|
290
|
+
key = expect(:ident).value
|
|
291
|
+
node = Member.new(node, key)
|
|
292
|
+
elsif at?(:lbracket)
|
|
293
|
+
advance
|
|
294
|
+
idx = parse_expr
|
|
295
|
+
expect(:rbracket)
|
|
296
|
+
node = Index.new(node, idx)
|
|
297
|
+
else
|
|
298
|
+
break
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
node
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def parse_primary
|
|
305
|
+
t = peek
|
|
306
|
+
case t.type
|
|
307
|
+
when :number, :string then advance; Lit.new(t.value)
|
|
308
|
+
when :true_kw, :false_kw, :null_kw then advance; Lit.new(t.value)
|
|
309
|
+
when :lbracket then parse_array
|
|
310
|
+
when :lbrace then parse_object
|
|
311
|
+
when :lparen then parse_function_or_group
|
|
312
|
+
when :ident then advance; Ident.new(t.value)
|
|
313
|
+
when :at then parse_fileref
|
|
314
|
+
else raise ParseError, "Unexpected token #{t.type} (#{t.value.inspect}) at #{t.pos}"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def parse_fileref
|
|
319
|
+
expect(:at)
|
|
320
|
+
# Bare "@" = current file: not followed by something that can begin a path.
|
|
321
|
+
nxt = peek
|
|
322
|
+
starts_path = (nxt.type == :ident) || (nxt.type == :dot && peek(1)&.type == :dot)
|
|
323
|
+
return FileRef.new(:self, nil) unless starts_path
|
|
324
|
+
# refpath: { "../" } segment { "/" segment }
|
|
325
|
+
parts = []
|
|
326
|
+
has_dotdot = false
|
|
327
|
+
while at?(:dot) && peek(1)&.type == :dot
|
|
328
|
+
advance; advance # consume the two dots of ..
|
|
329
|
+
parts << ".."
|
|
330
|
+
expect(:slash)
|
|
331
|
+
has_dotdot = true
|
|
332
|
+
end
|
|
333
|
+
parts << expect(:ident).value
|
|
334
|
+
while at?(:slash)
|
|
335
|
+
advance
|
|
336
|
+
parts << expect(:ident).value
|
|
337
|
+
end
|
|
338
|
+
# A reference is eligible for builtin/stdlib fallback (:name) iff it does NOT
|
|
339
|
+
# contain "../". Downward paths like "dir/a" are still eligible; only "../"
|
|
340
|
+
# (escaping upward) forces pure file-path (:path) resolution.
|
|
341
|
+
bare = !has_dotdot
|
|
342
|
+
FileRef.new(bare ? :name : :path, parts.join("/"))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def parse_array
|
|
346
|
+
expect(:lbracket)
|
|
347
|
+
elems = []
|
|
348
|
+
until at?(:rbracket)
|
|
349
|
+
if at?(:spread)
|
|
350
|
+
advance
|
|
351
|
+
elems << [:spread, parse_expr]
|
|
352
|
+
else
|
|
353
|
+
elems << [:item, parse_expr]
|
|
354
|
+
end
|
|
355
|
+
break unless at?(:comma)
|
|
356
|
+
advance
|
|
357
|
+
end
|
|
358
|
+
expect(:rbracket)
|
|
359
|
+
ArrLit.new(elems)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def parse_object
|
|
363
|
+
expect(:lbrace)
|
|
364
|
+
members = []
|
|
365
|
+
until at?(:rbrace)
|
|
366
|
+
if at?(:spread)
|
|
367
|
+
advance
|
|
368
|
+
members << [:spread, parse_expr]
|
|
369
|
+
else
|
|
370
|
+
key = expect(:string).value
|
|
371
|
+
expect(:colon)
|
|
372
|
+
members << [:kv, key, parse_expr]
|
|
373
|
+
end
|
|
374
|
+
break unless at?(:comma)
|
|
375
|
+
advance
|
|
376
|
+
end
|
|
377
|
+
expect(:rbrace)
|
|
378
|
+
ObjLit.new(members)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# A "(" can begin either a grouped expression or a function literal.
|
|
382
|
+
# Distinguish by trying to parse a clause: a function is a comma-separated
|
|
383
|
+
# list of `pattern => expr`. We detect a function by scanning for `=>`
|
|
384
|
+
# before the matching `)` at depth 0.
|
|
385
|
+
def parse_function_or_group
|
|
386
|
+
expect(:lparen)
|
|
387
|
+
if looks_like_function?
|
|
388
|
+
clauses = []
|
|
389
|
+
loop do
|
|
390
|
+
pat = parse_pattern
|
|
391
|
+
expect(:arrow)
|
|
392
|
+
body = parse_expr
|
|
393
|
+
clauses << [pat, body]
|
|
394
|
+
if at?(:comma)
|
|
395
|
+
advance
|
|
396
|
+
break if at?(:rparen) # trailing comma
|
|
397
|
+
else
|
|
398
|
+
break
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
expect(:rparen)
|
|
402
|
+
FuncLit.new(clauses)
|
|
403
|
+
else
|
|
404
|
+
e = parse_expr
|
|
405
|
+
expect(:rparen)
|
|
406
|
+
e
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Look ahead from current position (just after "(") to decide if this is a
|
|
411
|
+
# function literal: is there a top-level "=>" before the matching ")"?
|
|
412
|
+
def looks_like_function?
|
|
413
|
+
depth = 0
|
|
414
|
+
j = @i
|
|
415
|
+
while j < @toks.length
|
|
416
|
+
t = @toks[j]
|
|
417
|
+
case t.type
|
|
418
|
+
when :lparen, :lbracket, :lbrace then depth += 1
|
|
419
|
+
when :rparen, :rbracket, :rbrace
|
|
420
|
+
return false if depth.zero? # hit our closing ) first
|
|
421
|
+
depth -= 1
|
|
422
|
+
when :arrow
|
|
423
|
+
return true if depth.zero?
|
|
424
|
+
when :eof
|
|
425
|
+
return false
|
|
426
|
+
end
|
|
427
|
+
j += 1
|
|
428
|
+
end
|
|
429
|
+
false
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# ---- Patterns ----
|
|
433
|
+
# ---- Pattern grammar (mirrors reference.md §2.5 EBNF) ------------------
|
|
434
|
+
# pattern = errpat | guardedpat
|
|
435
|
+
# errpat = "!" | "!" guardedpat
|
|
436
|
+
# guardedpat = corepat [ "?" predicate ]
|
|
437
|
+
# corepat = literalpat | bindpat | wildcard | arraypat | objectpat
|
|
438
|
+
# Note: `corepat` does NOT include errpat. The "no nested !pat" property
|
|
439
|
+
# falls out of the grammar shape — `errpat` is only reachable from `pattern`
|
|
440
|
+
# (a clause's top level), never from inside arrays, objects, or another
|
|
441
|
+
# error's payload. No flag-threading is needed.
|
|
442
|
+
def parse_pattern
|
|
443
|
+
at?(:bang) ? parse_errpat : parse_guardedpat
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Tokens that can begin a `guardedpat` (used to detect whether `!` is
|
|
447
|
+
# followed by a payload pattern or stands alone).
|
|
448
|
+
GUARDEDPAT_STARTERS = %i[number string true_kw false_kw null_kw
|
|
449
|
+
lbracket lbrace ident].freeze
|
|
450
|
+
|
|
451
|
+
def parse_errpat
|
|
452
|
+
expect(:bang)
|
|
453
|
+
if GUARDEDPAT_STARTERS.include?(peek.type)
|
|
454
|
+
PErr.new(parse_guardedpat) # "!" guardedpat
|
|
455
|
+
else
|
|
456
|
+
PErr.new(PWild.new(nil)) # bare "!" — matches any error, binds nothing
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def parse_guardedpat
|
|
461
|
+
inner = parse_corepat
|
|
462
|
+
if at?(:question)
|
|
463
|
+
advance
|
|
464
|
+
pred = parse_prefix
|
|
465
|
+
PGuard.new(inner, pred)
|
|
466
|
+
else
|
|
467
|
+
inner
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def parse_corepat
|
|
472
|
+
t = peek
|
|
473
|
+
case t.type
|
|
474
|
+
when :number, :string then advance; PLit.new(t.value)
|
|
475
|
+
when :true_kw, :false_kw, :null_kw then advance; PLit.new(t.value)
|
|
476
|
+
when :lbracket then parse_arraypat
|
|
477
|
+
when :lbrace then parse_objectpat
|
|
478
|
+
when :ident
|
|
479
|
+
advance
|
|
480
|
+
t.value == "_" ? PWild.new(nil) : PBind.new(t.value)
|
|
481
|
+
when :bang
|
|
482
|
+
# `!pat` is only valid as a clause's top-level pattern, never inside an
|
|
483
|
+
# array element, object member, or error payload.
|
|
484
|
+
raise ParseError, "`!pat` may only appear as a clause's top-level pattern (at #{t.pos})"
|
|
485
|
+
else
|
|
486
|
+
raise ParseError, "Unexpected token in pattern: #{t.type} at #{t.pos}"
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def parse_arraypat
|
|
491
|
+
# Array elements are `guardedpat`s — they cannot be error patterns.
|
|
492
|
+
expect(:lbracket)
|
|
493
|
+
elems = []
|
|
494
|
+
until at?(:rbracket)
|
|
495
|
+
if at?(:spread)
|
|
496
|
+
advance
|
|
497
|
+
name = at?(:ident) ? advance.value : nil
|
|
498
|
+
elems << [:rest, name]
|
|
499
|
+
else
|
|
500
|
+
elems << [:pat, parse_guardedpat]
|
|
501
|
+
end
|
|
502
|
+
break unless at?(:comma)
|
|
503
|
+
advance
|
|
504
|
+
end
|
|
505
|
+
expect(:rbracket)
|
|
506
|
+
PArr.new(elems)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def parse_objectpat
|
|
510
|
+
# Object members are `guardedpat`s — they cannot be error patterns.
|
|
511
|
+
expect(:lbrace)
|
|
512
|
+
members = []
|
|
513
|
+
until at?(:rbrace)
|
|
514
|
+
if at?(:spread)
|
|
515
|
+
advance
|
|
516
|
+
name = at?(:ident) ? advance.value : nil
|
|
517
|
+
members << [:rest, name]
|
|
518
|
+
else
|
|
519
|
+
key = expect(:string).value
|
|
520
|
+
expect(:colon)
|
|
521
|
+
members << [:kv, key, parse_guardedpat]
|
|
522
|
+
end
|
|
523
|
+
break unless at?(:comma)
|
|
524
|
+
advance
|
|
525
|
+
end
|
|
526
|
+
expect(:rbrace)
|
|
527
|
+
PObj.new(members)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# ---- token helpers ----
|
|
531
|
+
def peek(o = 0) = @toks[@i + o]
|
|
532
|
+
def at?(type) = peek.type == type
|
|
533
|
+
def advance = (@toks[@i].tap { @i += 1 })
|
|
534
|
+
def expect(type)
|
|
535
|
+
t = peek
|
|
536
|
+
raise ParseError, "Expected #{type} but got #{t.type} (#{t.value.inspect}) at #{t.pos}" unless t.type == type
|
|
537
|
+
advance
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# =========================================================================
|
|
542
|
+
# RUNTIME VALUES
|
|
543
|
+
# =========================================================================
|
|
544
|
+
# A function closes over the environment in which it was defined.
|
|
545
|
+
class Func
|
|
546
|
+
attr_reader :clauses, :env
|
|
547
|
+
def initialize(clauses, env)
|
|
548
|
+
@clauses = clauses # [[pattern, expr_ast], ...]
|
|
549
|
+
@env = env
|
|
550
|
+
end
|
|
551
|
+
def inspect = "<func/#{clauses.length}>"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Lazy, memoized reference to a file's value (a "thunk" / promise).
|
|
555
|
+
class FileThunk
|
|
556
|
+
def initialize(loader, abspath)
|
|
557
|
+
@loader = loader
|
|
558
|
+
@abspath = abspath
|
|
559
|
+
@state = :unforced # :unforced | :forcing | :done
|
|
560
|
+
@value = nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def force
|
|
564
|
+
case @state
|
|
565
|
+
when :done then @value
|
|
566
|
+
when :forcing
|
|
567
|
+
# We are already evaluating this file and were asked for it again
|
|
568
|
+
# without any intervening function boundary => non-productive data cycle.
|
|
569
|
+
Fusion.mkerr({"kind" => "data_cycle", "path" => @abspath})
|
|
570
|
+
else
|
|
571
|
+
@state = :forcing
|
|
572
|
+
@value = @loader.evaluate_file(@abspath)
|
|
573
|
+
@state = :done
|
|
574
|
+
@value
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Environment: maps names -> values, with a parent chain. Built-ins live at root.
|
|
580
|
+
class Env
|
|
581
|
+
def initialize(parent = nil)
|
|
582
|
+
@vars = {}
|
|
583
|
+
@parent = parent
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def define(name, value)
|
|
587
|
+
@vars[name] = value
|
|
588
|
+
self
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def lookup(name)
|
|
592
|
+
if @vars.key?(name)
|
|
593
|
+
@vars[name]
|
|
594
|
+
elsif @parent
|
|
595
|
+
@parent.lookup(name)
|
|
596
|
+
else
|
|
597
|
+
:__unbound__
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def child(bindings = {})
|
|
602
|
+
e = Env.new(self)
|
|
603
|
+
bindings.each { |k, v| e.define(k, v) }
|
|
604
|
+
e
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# =========================================================================
|
|
609
|
+
# EVALUATOR
|
|
610
|
+
# =========================================================================
|
|
611
|
+
class Interpreter
|
|
612
|
+
attr_reader :root_env
|
|
613
|
+
|
|
614
|
+
def initialize(stdlib_dir: nil, env_vars: nil)
|
|
615
|
+
@stdlib_dir = stdlib_dir
|
|
616
|
+
@env_vars = env_vars || ENV.to_h
|
|
617
|
+
@file_cache = {} # abspath -> FileThunk
|
|
618
|
+
@ast_cache = {} # abspath -> AST
|
|
619
|
+
@builtins = {} # name -> NativeFunc (consulted by @name, not via env)
|
|
620
|
+
Builtins.install(@builtins, self)
|
|
621
|
+
@root_env = Env.new # holds no builtins now; bare identifiers are holes only
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# ---- File loading -----------------------------------------------------
|
|
625
|
+
def load_file(abspath)
|
|
626
|
+
@file_cache[abspath] ||= FileThunk.new(self, abspath)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def evaluate_file(abspath)
|
|
630
|
+
ast = (@ast_cache[abspath] ||= begin
|
|
631
|
+
src = File.read(abspath)
|
|
632
|
+
Parser.parse_file(src)
|
|
633
|
+
end)
|
|
634
|
+
# A file's value is evaluated in a fresh env whose parent is root (builtins),
|
|
635
|
+
# plus knowledge of its own directory for resolving @refs.
|
|
636
|
+
env = @root_env.child
|
|
637
|
+
env.define("__dir__", File.dirname(abspath))
|
|
638
|
+
env.define("__file__", abspath)
|
|
639
|
+
eval_expr(ast, env)
|
|
640
|
+
rescue Errno::ENOENT
|
|
641
|
+
warn "[fusion] file not found: #{abspath}" if ENV["FUSION_DEBUG"]
|
|
642
|
+
Fusion.mkerr({"kind" => "file_not_found", "path" => abspath})
|
|
643
|
+
rescue ParseError => err
|
|
644
|
+
warn "[fusion] parse error in #{abspath}: #{err.message}" if ENV["FUSION_DEBUG"]
|
|
645
|
+
Fusion.mkerr({"kind" => "parse_error", "path" => abspath, "message" => err.message})
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
|
|
649
|
+
def resolve_name(name, dir)
|
|
650
|
+
sib = File.expand_path(name + ".fsn", dir)
|
|
651
|
+
return load_file(sib).force if File.exist?(sib)
|
|
652
|
+
if name == "ENV"
|
|
653
|
+
return @env_vars.dup
|
|
654
|
+
end
|
|
655
|
+
if name == "load"
|
|
656
|
+
# @load is a builtin closure capturing the calling file's directory. It
|
|
657
|
+
# loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
|
|
658
|
+
d = dir
|
|
659
|
+
return NativeFunc.new("load", lambda do |v|
|
|
660
|
+
next Fusion.mkerr({"kind" => "load_bad_arg", "got" => v.class.name}) unless v.is_a?(String)
|
|
661
|
+
target = File.expand_path(v, d)
|
|
662
|
+
next Fusion.mkerr({"kind" => "file_not_found", "path" => target}) unless File.exist?(target)
|
|
663
|
+
load_file(target).force
|
|
664
|
+
end)
|
|
665
|
+
end
|
|
666
|
+
return @builtins[name] if @builtins.key?(name)
|
|
667
|
+
if @stdlib_dir
|
|
668
|
+
std = File.join(@stdlib_dir, name + ".fsn")
|
|
669
|
+
return load_file(std).force if File.exist?(std)
|
|
670
|
+
end
|
|
671
|
+
Fusion.mkerr({"kind" => "unresolved_ref", "name" => name})
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
|
|
675
|
+
def resolve_path(relpath, dir)
|
|
676
|
+
load_file(File.expand_path(relpath + ".fsn", dir)).force
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# ---- Expression evaluation -------------------------------------------
|
|
680
|
+
def eval_expr(node, env)
|
|
681
|
+
case node
|
|
682
|
+
when Lit then node.value
|
|
683
|
+
when ErrLit
|
|
684
|
+
# Bare `!` means `!null`; `!expr` wraps expr's value as an error.
|
|
685
|
+
# If the payload expression itself errors, propagate THAT error rather
|
|
686
|
+
# than wrapping it -- prevents accidental error-burying.
|
|
687
|
+
if node.payload.nil?
|
|
688
|
+
Fusion.mkerr(NULL)
|
|
689
|
+
else
|
|
690
|
+
p = eval_expr(node.payload, env)
|
|
691
|
+
Fusion.error?(p) ? p : Fusion.mkerr(p)
|
|
692
|
+
end
|
|
693
|
+
when Ident
|
|
694
|
+
v = env.lookup(node.name)
|
|
695
|
+
v == :__unbound__ ? Fusion.mkerr({"kind" => "unbound", "name" => node.name}) : v
|
|
696
|
+
when FileRef
|
|
697
|
+
dir = env.lookup("__dir__")
|
|
698
|
+
dir = Dir.pwd if dir == :__unbound__
|
|
699
|
+
case node.variety
|
|
700
|
+
when :self
|
|
701
|
+
f = env.lookup("__file__")
|
|
702
|
+
f == :__unbound__ ? Fusion.mkerr({"kind" => "no_current_file"}) : load_file(f).force
|
|
703
|
+
when :name
|
|
704
|
+
resolve_name(node.path, dir)
|
|
705
|
+
else # :path
|
|
706
|
+
resolve_path(node.path, dir)
|
|
707
|
+
end
|
|
708
|
+
when ArrLit then eval_array(node, env)
|
|
709
|
+
when ObjLit then eval_object(node, env)
|
|
710
|
+
when FuncLit then Func.new(node.clauses, env)
|
|
711
|
+
when Pipe then eval_pipe(node, env)
|
|
712
|
+
when Member then eval_member(node, env)
|
|
713
|
+
when Index then eval_index(node, env)
|
|
714
|
+
else raise FusionError, "Cannot evaluate node #{node.class}"
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Array/object literals propagate any error encountered during construction.
|
|
719
|
+
# Errors are not first-class: at any point during execution there is either
|
|
720
|
+
# a value or an error in motion, never both.
|
|
721
|
+
def eval_array(node, env)
|
|
722
|
+
out = []
|
|
723
|
+
node.elems.each do |kind, expr|
|
|
724
|
+
v = eval_expr(expr, env)
|
|
725
|
+
return v if Fusion.error?(v)
|
|
726
|
+
if kind == :spread
|
|
727
|
+
return Fusion.mkerr({"kind" => "spread_non_array", "got" => v.class.name}) unless v.is_a?(Array)
|
|
728
|
+
out.concat(v)
|
|
729
|
+
else
|
|
730
|
+
out << v
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
out
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def eval_object(node, env)
|
|
737
|
+
out = {}
|
|
738
|
+
node.members.each do |m|
|
|
739
|
+
if m[0] == :spread
|
|
740
|
+
v = eval_expr(m[1], env)
|
|
741
|
+
return v if Fusion.error?(v)
|
|
742
|
+
return Fusion.mkerr({"kind" => "spread_non_object", "got" => v.class.name}) unless v.is_a?(Hash)
|
|
743
|
+
out.merge!(v)
|
|
744
|
+
else
|
|
745
|
+
_, key, expr = m
|
|
746
|
+
v = eval_expr(expr, env)
|
|
747
|
+
return v if Fusion.error?(v)
|
|
748
|
+
out[key] = v
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
out
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def eval_pipe(node, env)
|
|
755
|
+
v = eval_expr(node.left, env)
|
|
756
|
+
f = eval_expr(node.right, env)
|
|
757
|
+
apply(f, v)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def eval_member(node, env)
|
|
761
|
+
obj = eval_expr(node.obj, env)
|
|
762
|
+
return obj if Fusion.error?(obj)
|
|
763
|
+
return Fusion.mkerr({"kind" => "member_on_non_object", "key" => node.key}) unless obj.is_a?(Hash)
|
|
764
|
+
return Fusion.mkerr({"kind" => "missing_key", "key" => node.key}) unless obj.key?(node.key)
|
|
765
|
+
obj[node.key]
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def eval_index(node, env)
|
|
769
|
+
obj = eval_expr(node.obj, env)
|
|
770
|
+
return obj if Fusion.error?(obj)
|
|
771
|
+
idx = eval_expr(node.idx, env)
|
|
772
|
+
return idx if Fusion.error?(idx)
|
|
773
|
+
if obj.is_a?(Array) && idx.is_a?(Integer)
|
|
774
|
+
i = idx >= 0 ? idx : obj.length + idx
|
|
775
|
+
return obj[i] if i >= 0 && i < obj.length
|
|
776
|
+
return Fusion.mkerr({"kind" => "index_out_of_range", "index" => idx, "length" => obj.length})
|
|
777
|
+
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
778
|
+
return obj[idx] if obj.key?(idx)
|
|
779
|
+
return Fusion.mkerr({"kind" => "missing_key", "key" => idx})
|
|
780
|
+
end
|
|
781
|
+
Fusion.mkerr({"kind" => "bad_index", "obj" => obj.class.name, "idx" => idx.class.name})
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# ---- Application & matching ------------------------------------------
|
|
785
|
+
def apply(f, v)
|
|
786
|
+
# An errored function value propagates as-is (more useful than a generic
|
|
787
|
+
# "applied a non-function" wrapper).
|
|
788
|
+
return f if Fusion.error?(f)
|
|
789
|
+
if f.is_a?(NativeFunc)
|
|
790
|
+
# Uniform propagation: built-ins never receive errors as inputs.
|
|
791
|
+
return v if Fusion.error?(v)
|
|
792
|
+
return f.fn.call(v)
|
|
793
|
+
end
|
|
794
|
+
unless f.is_a?(Func)
|
|
795
|
+
return Fusion.mkerr({"kind" => "apply_non_function", "got" => f.class.name})
|
|
796
|
+
end
|
|
797
|
+
f.clauses.each do |pattern, body|
|
|
798
|
+
bindings = {}
|
|
799
|
+
m = match(pattern, v, bindings, f.env)
|
|
800
|
+
# A `?` predicate raised an error during matching: bubble it up as the
|
|
801
|
+
# function's return value (no further clauses are tried).
|
|
802
|
+
return m if Fusion.error?(m)
|
|
803
|
+
if m
|
|
804
|
+
return eval_expr(body, f.env.child(bindings))
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
# No clause matched. If the input was an error, it keeps propagating
|
|
808
|
+
# (an unmatched error must never be silently swallowed). Otherwise the
|
|
809
|
+
# lenient default is `null`.
|
|
810
|
+
Fusion.error?(v) ? v : NULL
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# Returns true (match), false (no match), or an ErrorVal (predicate errored).
|
|
814
|
+
def match(pattern, value, bindings, env)
|
|
815
|
+
case pattern
|
|
816
|
+
when PLit
|
|
817
|
+
deep_equal?(pattern.value, value)
|
|
818
|
+
when PErr
|
|
819
|
+
# If the value is an error, match the inner pattern against its
|
|
820
|
+
# payload. The inner is always a non-`!` pattern (the parser ensures
|
|
821
|
+
# that), so for a bare `!` we synthesized PWild as the inner.
|
|
822
|
+
return false unless Fusion.error?(value)
|
|
823
|
+
match(pattern.inner, value.payload, bindings, env)
|
|
824
|
+
when PWild
|
|
825
|
+
# `_` matches anything EXCEPT an error value.
|
|
826
|
+
!Fusion.error?(value)
|
|
827
|
+
when PBind
|
|
828
|
+
return false if Fusion.error?(value) # binders never capture an error
|
|
829
|
+
bindings[pattern.name] = value
|
|
830
|
+
true
|
|
831
|
+
when PArr
|
|
832
|
+
match_array(pattern, value, bindings, env)
|
|
833
|
+
when PObj
|
|
834
|
+
match_object(pattern, value, bindings, env)
|
|
835
|
+
when PGuard
|
|
836
|
+
inner_res = match(pattern.inner, value, bindings, env)
|
|
837
|
+
return inner_res if Fusion.error?(inner_res)
|
|
838
|
+
return false unless inner_res
|
|
839
|
+
pred = eval_expr(pattern.pred_expr, env)
|
|
840
|
+
return pred if Fusion.error?(pred) # predicate expression itself errored
|
|
841
|
+
# The predicate sees whatever value reached this PGuard, which is
|
|
842
|
+
# already the right value because `!pat ? pred` parses as
|
|
843
|
+
# PErr(PGuard(pat, pred)) — by the time PGuard runs, the value is
|
|
844
|
+
# already the payload.
|
|
845
|
+
r = apply(pred, value)
|
|
846
|
+
return r if Fusion.error?(r) # predicate raised during application
|
|
847
|
+
r == true
|
|
848
|
+
else
|
|
849
|
+
raise FusionError, "Unknown pattern #{pattern.class}"
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def match_array(pattern, value, bindings, env)
|
|
854
|
+
return false unless value.is_a?(Array)
|
|
855
|
+
elems = pattern.elems
|
|
856
|
+
rest_index = elems.index { |e| e[0] == :rest }
|
|
857
|
+
|
|
858
|
+
if rest_index.nil?
|
|
859
|
+
return false unless value.length == elems.length
|
|
860
|
+
elems.each_with_index do |(_, p), i|
|
|
861
|
+
r = match(p, value[i], bindings, env)
|
|
862
|
+
return r if Fusion.error?(r)
|
|
863
|
+
return false unless r
|
|
864
|
+
end
|
|
865
|
+
true
|
|
866
|
+
else
|
|
867
|
+
before = elems[0...rest_index]
|
|
868
|
+
after = elems[(rest_index + 1)..]
|
|
869
|
+
return false if value.length < before.length + after.length
|
|
870
|
+
before.each_with_index do |(_, p), i|
|
|
871
|
+
r = match(p, value[i], bindings, env)
|
|
872
|
+
return r if Fusion.error?(r)
|
|
873
|
+
return false unless r
|
|
874
|
+
end
|
|
875
|
+
after.each_with_index do |(_, p), k|
|
|
876
|
+
vi = value.length - after.length + k
|
|
877
|
+
r = match(p, value[vi], bindings, env)
|
|
878
|
+
return r if Fusion.error?(r)
|
|
879
|
+
return false unless r
|
|
880
|
+
end
|
|
881
|
+
rest_name = elems[rest_index][1]
|
|
882
|
+
if rest_name
|
|
883
|
+
mid = value[before.length...(value.length - after.length)]
|
|
884
|
+
bindings[rest_name] = mid
|
|
885
|
+
end
|
|
886
|
+
true
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def match_object(pattern, value, bindings, env)
|
|
891
|
+
return false unless value.is_a?(Hash)
|
|
892
|
+
matched_keys = []
|
|
893
|
+
rest_name = :__none__
|
|
894
|
+
pattern.members.each do |m|
|
|
895
|
+
if m[0] == :rest
|
|
896
|
+
rest_name = m[1] # may be nil (ignore) or a string
|
|
897
|
+
else
|
|
898
|
+
_, key, p = m
|
|
899
|
+
return false unless value.key?(key)
|
|
900
|
+
r = match(p, value[key], bindings, env)
|
|
901
|
+
return r if Fusion.error?(r)
|
|
902
|
+
return false unless r
|
|
903
|
+
matched_keys << key
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
if rest_name != :__none__ && rest_name
|
|
907
|
+
remaining = value.reject { |k, _| matched_keys.include?(k) }
|
|
908
|
+
bindings[rest_name] = remaining
|
|
909
|
+
end
|
|
910
|
+
true
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# ---- Equality & helpers ----------------------------------------------
|
|
914
|
+
def error?(v) = Fusion.error?(v)
|
|
915
|
+
|
|
916
|
+
def deep_equal?(a, b)
|
|
917
|
+
return true if a.equal?(b)
|
|
918
|
+
return false if a.class != b.class
|
|
919
|
+
case a
|
|
920
|
+
when Array
|
|
921
|
+
a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
|
|
922
|
+
when Hash
|
|
923
|
+
a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
|
|
924
|
+
else
|
|
925
|
+
a == b
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# =========================================================================
|
|
931
|
+
# BUILT-INS (Tier 0 primitives; everything else is written in Fusion)
|
|
932
|
+
# =========================================================================
|
|
933
|
+
module Builtins
|
|
934
|
+
def self.install(table, interp)
|
|
935
|
+
# Helper to construct an informative error from a builtin context.
|
|
936
|
+
err = ->(fn, msg) { Fusion.mkerr("#{fn}: #{msg}") }
|
|
937
|
+
define = ->(name, fn) { table[name] = NativeFunc.new(name, fn) }
|
|
938
|
+
|
|
939
|
+
# --- arithmetic on a pair [a, b] (or unary for negate) ---
|
|
940
|
+
pair_num = lambda do |v|
|
|
941
|
+
return nil unless v.is_a?(Array) && v.length == 2
|
|
942
|
+
a, b = v
|
|
943
|
+
return nil unless a.is_a?(Numeric) && !(a == true || a == false) &&
|
|
944
|
+
b.is_a?(Numeric) && !(b == true || b == false)
|
|
945
|
+
[a, b]
|
|
946
|
+
end
|
|
947
|
+
isnum = ->(x) { x.is_a?(Numeric) && !(x == true || x == false) }
|
|
948
|
+
|
|
949
|
+
define.call("add", ->(v) {
|
|
950
|
+
p = pair_num.call(v); p ? p[0] + p[1] : err.call("add", "expected a pair of numbers")
|
|
951
|
+
})
|
|
952
|
+
define.call("subtract", ->(v) {
|
|
953
|
+
p = pair_num.call(v); p ? p[0] - p[1] : err.call("subtract", "expected a pair of numbers")
|
|
954
|
+
})
|
|
955
|
+
define.call("multiply", ->(v) {
|
|
956
|
+
p = pair_num.call(v); p ? p[0] * p[1] : err.call("multiply", "expected a pair of numbers")
|
|
957
|
+
})
|
|
958
|
+
define.call("divide", lambda do |v|
|
|
959
|
+
p = pair_num.call(v)
|
|
960
|
+
next err.call("divide", "expected a pair of numbers") unless p
|
|
961
|
+
next err.call("divide", "division by zero") if p[1] == 0
|
|
962
|
+
if p[0].is_a?(Integer) && p[1].is_a?(Integer) && (p[0] % p[1] == 0)
|
|
963
|
+
p[0] / p[1]
|
|
964
|
+
else
|
|
965
|
+
p[0].to_f / p[1]
|
|
966
|
+
end
|
|
967
|
+
end)
|
|
968
|
+
define.call("mod", lambda do |v|
|
|
969
|
+
p = pair_num.call(v)
|
|
970
|
+
next err.call("mod", "expected a pair of numbers") unless p
|
|
971
|
+
next err.call("mod", "modulo by zero") if p[1] == 0
|
|
972
|
+
p[0] % p[1]
|
|
973
|
+
end)
|
|
974
|
+
define.call("negate", ->(v) {
|
|
975
|
+
isnum.call(v) ? -v : err.call("negate", "expected a number")
|
|
976
|
+
})
|
|
977
|
+
define.call("floor", ->(v) {
|
|
978
|
+
isnum.call(v) ? v.floor : err.call("floor", "expected a number")
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
# --- comparison ---
|
|
982
|
+
define.call("equals", lambda do |v|
|
|
983
|
+
next err.call("equals", "expected a pair") unless v.is_a?(Array) && v.length == 2
|
|
984
|
+
interp.deep_equal?(v[0], v[1])
|
|
985
|
+
end)
|
|
986
|
+
define.call("lessThan", lambda do |v|
|
|
987
|
+
next err.call("lessThan", "expected two numbers or two strings") unless v.is_a?(Array) && v.length == 2
|
|
988
|
+
a, b = v
|
|
989
|
+
if isnum.call(a) && isnum.call(b) then a < b
|
|
990
|
+
elsif a.is_a?(String) && b.is_a?(String) then a < b
|
|
991
|
+
else err.call("lessThan", "expected two numbers or two strings") end
|
|
992
|
+
end)
|
|
993
|
+
|
|
994
|
+
# --- boolean ---
|
|
995
|
+
define.call("and", lambda do |v|
|
|
996
|
+
unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x == true || x == false }
|
|
997
|
+
next err.call("and", "expected a pair of booleans")
|
|
998
|
+
end
|
|
999
|
+
v[0] && v[1]
|
|
1000
|
+
end)
|
|
1001
|
+
define.call("or", lambda do |v|
|
|
1002
|
+
unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x == true || x == false }
|
|
1003
|
+
next err.call("or", "expected a pair of booleans")
|
|
1004
|
+
end
|
|
1005
|
+
v[0] || v[1]
|
|
1006
|
+
end)
|
|
1007
|
+
define.call("not", ->(v) {
|
|
1008
|
+
(v == true || v == false) ? !v : err.call("not", "expected a boolean")
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
# --- strings / structure bridges ---
|
|
1012
|
+
define.call("length", lambda do |v|
|
|
1013
|
+
case v
|
|
1014
|
+
when String then v.length
|
|
1015
|
+
when Array then v.length
|
|
1016
|
+
when Hash then v.length
|
|
1017
|
+
else err.call("length", "expected a string, array, or object") end
|
|
1018
|
+
end)
|
|
1019
|
+
define.call("concat", lambda do |v|
|
|
1020
|
+
unless v.is_a?(Array) && v.length == 2 && v.all? { |x| x.is_a?(String) }
|
|
1021
|
+
next err.call("concat", "expected a pair of strings")
|
|
1022
|
+
end
|
|
1023
|
+
v[0] + v[1]
|
|
1024
|
+
end)
|
|
1025
|
+
define.call("chars", ->(v) {
|
|
1026
|
+
v.is_a?(String) ? v.chars : err.call("chars", "expected a string")
|
|
1027
|
+
})
|
|
1028
|
+
define.call("join", lambda do |v|
|
|
1029
|
+
next err.call("join", "expected [array-of-strings, separator-string]") unless v.is_a?(Array) && v.length == 2
|
|
1030
|
+
arr, sep = v
|
|
1031
|
+
unless arr.is_a?(Array) && sep.is_a?(String) && arr.all? { |x| x.is_a?(String) }
|
|
1032
|
+
next err.call("join", "expected [array-of-strings, separator-string]")
|
|
1033
|
+
end
|
|
1034
|
+
arr.join(sep)
|
|
1035
|
+
end)
|
|
1036
|
+
define.call("toString", lambda do |v|
|
|
1037
|
+
case v
|
|
1038
|
+
when String then v
|
|
1039
|
+
when Integer, Float then v.to_s
|
|
1040
|
+
when true then "true"
|
|
1041
|
+
when false then "false"
|
|
1042
|
+
when NULL then "null"
|
|
1043
|
+
else err.call("toString", "cannot stringify this value type")
|
|
1044
|
+
end
|
|
1045
|
+
end)
|
|
1046
|
+
define.call("parseNumber", lambda do |v|
|
|
1047
|
+
next err.call("parseNumber", "expected a string") unless v.is_a?(String)
|
|
1048
|
+
if v =~ /\A-?\d+\z/ then v.to_i
|
|
1049
|
+
elsif v =~ /\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/ then v.to_f
|
|
1050
|
+
else err.call("parseNumber", "not a numeric string")
|
|
1051
|
+
end
|
|
1052
|
+
end)
|
|
1053
|
+
|
|
1054
|
+
# --- object key enumeration (Tier 0: patterns can't enumerate unknown keys) ---
|
|
1055
|
+
define.call("keys", ->(v) { v.is_a?(Hash) ? v.keys : err.call("keys", "expected an object") })
|
|
1056
|
+
define.call("values", ->(v) { v.is_a?(Hash) ? v.values : err.call("values", "expected an object") })
|
|
1057
|
+
|
|
1058
|
+
# --- type predicates (return false on any non-matching value; propagate on error like every other builtin) ---
|
|
1059
|
+
define.call("Integer", ->(v) { v.is_a?(Integer) && !(v == true || v == false) })
|
|
1060
|
+
define.call("Float", ->(v) { v.is_a?(Float) })
|
|
1061
|
+
define.call("Number", ->(v) { isnum.call(v) })
|
|
1062
|
+
define.call("String", ->(v) { v.is_a?(String) })
|
|
1063
|
+
define.call("Boolean", ->(v) { v == true || v == false })
|
|
1064
|
+
define.call("Array", ->(v) { v.is_a?(Array) })
|
|
1065
|
+
define.call("Object", ->(v) { v.is_a?(Hash) })
|
|
1066
|
+
define.call("Null", ->(v) { v == NULL })
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# A native (Ruby-implemented) function. Apply treats it like a Func.
|
|
1071
|
+
class NativeFunc
|
|
1072
|
+
attr_reader :name, :fn
|
|
1073
|
+
def initialize(name, fn)
|
|
1074
|
+
@name = name
|
|
1075
|
+
@fn = fn
|
|
1076
|
+
end
|
|
1077
|
+
def inspect = "<builtin #{name}>"
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
# =========================================================================
|
|
1081
|
+
# JSON I/O (minimal, with NULL and ErrorVal handling)
|
|
1082
|
+
# =========================================================================
|
|
1083
|
+
module Serializer
|
|
1084
|
+
def self.to_json(v)
|
|
1085
|
+
case v
|
|
1086
|
+
when NULL then "null"
|
|
1087
|
+
when true then "true"
|
|
1088
|
+
when false then "false"
|
|
1089
|
+
when Integer then v.to_s
|
|
1090
|
+
when Float then v.to_s
|
|
1091
|
+
when String then string_json(v)
|
|
1092
|
+
when Array then "[" + v.map { |x| to_json(x) }.join(",") + "]"
|
|
1093
|
+
when Hash then "{" + v.map { |k, x| "#{string_json(k.to_s)}:#{to_json(x)}" }.join(",") + "}"
|
|
1094
|
+
when Func, NativeFunc then '"<function>"'
|
|
1095
|
+
when ErrorVal
|
|
1096
|
+
# Errors render as `!<payload-json>`. This form is NOT valid JSON; the CLI
|
|
1097
|
+
# prints the payload (as JSON) to stderr and nothing to stdout on error.
|
|
1098
|
+
"!" + to_json(v.payload)
|
|
1099
|
+
else
|
|
1100
|
+
v.inspect
|
|
1101
|
+
end
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
def self.string_json(s)
|
|
1105
|
+
out = +'"'
|
|
1106
|
+
s.each_char do |c|
|
|
1107
|
+
out << case c
|
|
1108
|
+
when '"' then '\\"'
|
|
1109
|
+
when "\\" then "\\\\"
|
|
1110
|
+
when "\n" then "\\n"
|
|
1111
|
+
when "\t" then "\\t"
|
|
1112
|
+
when "\r" then "\\r"
|
|
1113
|
+
else c
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
out << '"'
|
|
1117
|
+
out
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
module JsonInput
|
|
1122
|
+
# Parse JSON text into Fusion values (null -> NULL).
|
|
1123
|
+
def self.parse(text)
|
|
1124
|
+
require "json"
|
|
1125
|
+
raw = JSON.parse(text)
|
|
1126
|
+
convert(raw)
|
|
1127
|
+
rescue JSON::ParserError
|
|
1128
|
+
Fusion.mkerr({"kind" => "stdin_not_json"})
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
def self.convert(x)
|
|
1132
|
+
case x
|
|
1133
|
+
when nil then NULL
|
|
1134
|
+
when Array then x.map { |e| convert(e) }
|
|
1135
|
+
when Hash then x.each_with_object({}) { |(k, v), h| h[k] = convert(v) }
|
|
1136
|
+
else x
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
end
|