fusion-lang 0.0.1.alpha1 → 0.0.1.alpha2
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 +12 -0
- data/Rakefile +9 -0
- data/docs/lang/design.md +204 -3
- data/docs/lang/roadmap.md +1 -22
- data/docs/user/how-to-guides.md +5 -11
- data/docs/user/reference.md +342 -128
- data/docs/user/tutorial.md +11 -10
- data/examples/ends.fsn +4 -0
- data/examples/palindrome.fsn +2 -1
- data/exe/fusion +15 -42
- data/lib/fusion/ast.rb +96 -0
- data/lib/fusion/atom.rb +17 -0
- data/lib/fusion/cli/decoder.rb +79 -0
- data/lib/fusion/cli/encoder.rb +28 -0
- data/lib/fusion/cli/options.rb +142 -0
- data/lib/fusion/cli/parser.rb +38 -0
- data/lib/fusion/cli/repl.rb +73 -0
- data/lib/fusion/cli/serializer.rb +69 -0
- data/lib/fusion/cli.rb +136 -0
- data/lib/fusion/interpreter/builtins.rb +356 -0
- data/lib/fusion/interpreter/env.rb +59 -0
- data/lib/fusion/interpreter/error_val.rb +49 -0
- data/lib/fusion/interpreter/file_thunk.rb +39 -0
- data/lib/fusion/interpreter/func.rb +22 -0
- data/lib/fusion/interpreter/native_func.rb +22 -0
- data/lib/fusion/interpreter.rb +595 -0
- data/lib/fusion/lexer.rb +183 -0
- data/lib/fusion/null.rb +9 -0
- data/lib/fusion/parser.rb +404 -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/map.fsn +3 -1
- data/stdlib/mapValues.fsn +5 -0
- data/stdlib/math/square.fsn +4 -1
- data/stdlib/range.fsn +2 -1
- data/stdlib/sanitize.fsn +12 -0
- metadata +26 -1
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# === Transformation ===
|
|
4
|
+
#
|
|
5
|
+
# Tree-walking interpreter
|
|
6
|
+
#
|
|
7
|
+
# Input: AST::Expression
|
|
8
|
+
# Output: AST::Expression
|
|
9
|
+
|
|
10
|
+
# Values are represented in Ruby as:
|
|
11
|
+
# null -> :null (we avoid Ruby nil so "absent" is explicit)
|
|
12
|
+
# ! -> ErrorVal (always carries a payload; bare `!` means `!null`)
|
|
13
|
+
# bool -> true / false
|
|
14
|
+
# int -> Integer
|
|
15
|
+
# float -> Float
|
|
16
|
+
# string -> String
|
|
17
|
+
# array -> Array
|
|
18
|
+
# object -> Hash (String keys, insertion-ordered as Ruby preserves)
|
|
19
|
+
# func -> Func (closure over an Env)
|
|
20
|
+
|
|
21
|
+
require_relative "ast"
|
|
22
|
+
require_relative "null"
|
|
23
|
+
require_relative "interpreter/error_val"
|
|
24
|
+
require_relative "interpreter/func"
|
|
25
|
+
require_relative "interpreter/native_func"
|
|
26
|
+
require_relative "interpreter/builtins"
|
|
27
|
+
require_relative "interpreter/env"
|
|
28
|
+
require_relative "interpreter/file_thunk"
|
|
29
|
+
|
|
30
|
+
module Fusion
|
|
31
|
+
class Interpreter
|
|
32
|
+
include AST
|
|
33
|
+
|
|
34
|
+
attr_reader :root_env
|
|
35
|
+
|
|
36
|
+
def initialize(env_vars: nil)
|
|
37
|
+
@stdlib_dir = File.expand_path("../../stdlib", __dir__)
|
|
38
|
+
raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir)
|
|
39
|
+
|
|
40
|
+
@env_vars = env_vars || ENV.to_h
|
|
41
|
+
@file_cache = {} # abspath -> FileThunk
|
|
42
|
+
@ast_cache = {} # abspath -> AST
|
|
43
|
+
@builtins = {} # name -> NativeFunc (consulted by @name, not via env)
|
|
44
|
+
Builtins.install(@builtins, self)
|
|
45
|
+
@root_env = Env.new # holds no builtins now; bare identifiers are holes only
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Apply the program to one input behind a safety net: a Ruby-level failure
|
|
49
|
+
# (notably a stack overflow) becomes a payloaded error rather than a raw
|
|
50
|
+
# backtrace, so the stdout/stderr contract always holds. In the stream the
|
|
51
|
+
# error is one record's output and the next line continues.
|
|
52
|
+
def self.safe_apply(function, input)
|
|
53
|
+
safe do
|
|
54
|
+
new.apply(function, input)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Evaluate an expression behind the same per-run safety net as
|
|
59
|
+
# exe/fusion, so a Ruby-level failure becomes a printed payload and the
|
|
60
|
+
# session survives it. A statement carries its expression; a bare
|
|
61
|
+
# expression entry is the expression itself.
|
|
62
|
+
def self.safe_evaluate(expression, environment)
|
|
63
|
+
safe do
|
|
64
|
+
new.eval_expr(expression, environment)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.safe
|
|
69
|
+
yield
|
|
70
|
+
rescue Unreachable
|
|
71
|
+
# An interpreter bug. Allowed to surface.
|
|
72
|
+
raise
|
|
73
|
+
rescue StandardError => err
|
|
74
|
+
# TODO: change type
|
|
75
|
+
Interpreter::ErrorVal.internal(
|
|
76
|
+
kind: "type_error", location: "interpreter", operation: "running the program",
|
|
77
|
+
input: NULL, message: err.message
|
|
78
|
+
)
|
|
79
|
+
rescue SystemExit
|
|
80
|
+
# Let exit/abort through.
|
|
81
|
+
raise
|
|
82
|
+
rescue SystemStackError
|
|
83
|
+
Interpreter::ErrorVal.internal(
|
|
84
|
+
kind: "stack_error", location: "interpreter", operation: "running the program",
|
|
85
|
+
input: NULL, message: "recursion too deep"
|
|
86
|
+
)
|
|
87
|
+
rescue Exception => err # rubocop:disable Lint/RescueException
|
|
88
|
+
# Final net: any other escaped Ruby error becomes a payloaded error too.
|
|
89
|
+
# TODO: change type
|
|
90
|
+
Interpreter::ErrorVal.internal(
|
|
91
|
+
kind: "type_error", location: "interpreter", operation: "running the program",
|
|
92
|
+
input: NULL, message: err.message
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ---- File loading -----------------------------------------------------
|
|
97
|
+
def load_file(abspath)
|
|
98
|
+
@file_cache[abspath] ||= FileThunk.new(self, abspath)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The error field `location` for code at `abspath`.
|
|
102
|
+
def file_location(abspath)
|
|
103
|
+
if abspath.start_with?(@stdlib_dir + File::SEPARATOR)
|
|
104
|
+
"stdlib #{File.basename(abspath)}"
|
|
105
|
+
else
|
|
106
|
+
"code #{File.basename(abspath)}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The error field `location` for code being evaluated under `env`.
|
|
111
|
+
def code_location(env)
|
|
112
|
+
f = env.lookup("__file__")
|
|
113
|
+
if f == :__unbound__
|
|
114
|
+
# Inline (`-e`) programs have no file, so they report as "code <inline>".
|
|
115
|
+
"code <inline>"
|
|
116
|
+
else
|
|
117
|
+
file_location(f)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def evaluate_file(abspath)
|
|
122
|
+
loc = file_location(abspath)
|
|
123
|
+
ast = (@ast_cache[abspath] ||= begin
|
|
124
|
+
src = File.read(abspath)
|
|
125
|
+
Parser.parse_file(src, location: loc)
|
|
126
|
+
end)
|
|
127
|
+
|
|
128
|
+
if ast.is_a?(ErrorVal) # a parse error (already a payloaded value)
|
|
129
|
+
ast
|
|
130
|
+
else
|
|
131
|
+
# A file's value is evaluated in a fresh env whose parent is root (builtins),
|
|
132
|
+
# plus knowledge of its own directory for resolving @refs.
|
|
133
|
+
env = @root_env.child
|
|
134
|
+
env.define("__dir__", File.dirname(abspath))
|
|
135
|
+
env.define("__file__", abspath)
|
|
136
|
+
eval_expr(ast, env)
|
|
137
|
+
end
|
|
138
|
+
rescue Errno::ENOENT
|
|
139
|
+
ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: "file not found")
|
|
140
|
+
rescue SystemCallError => err # EISDIR, EACCES, ... — file-system access failures
|
|
141
|
+
ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: err.message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
|
|
145
|
+
# `location` is the "code X" of the referencing file (for the unresolved case).
|
|
146
|
+
def resolve_name(name, dir, location)
|
|
147
|
+
sibling_file = File.expand_path(name + ".fsn", dir)
|
|
148
|
+
if File.exist?(sibling_file)
|
|
149
|
+
return load_file(sibling_file).force
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if name == "ENV"
|
|
153
|
+
return @env_vars.dup
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if name == "load"
|
|
157
|
+
# @load is a builtin closure capturing the calling file's directory. It
|
|
158
|
+
# loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
|
|
159
|
+
d = dir
|
|
160
|
+
return NativeFunc.new("load", lambda do |v|
|
|
161
|
+
unless v.is_a?(String)
|
|
162
|
+
next ErrorVal.internal(kind: "type_error", location: "builtin load", operation: "@load", input: v, message: "expected a string")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
target = File.expand_path(v, d)
|
|
166
|
+
|
|
167
|
+
unless File.exist?(target)
|
|
168
|
+
next ErrorVal.internal(kind: "reference_error", location: "builtin load", operation: "@load", input: v, message: "file not found")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
load_file(target).force
|
|
172
|
+
end)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if @builtins.key?(name)
|
|
176
|
+
return @builtins[name]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
stdlib_file = File.join(@stdlib_dir, name + ".fsn")
|
|
180
|
+
if File.exist?(stdlib_file)
|
|
181
|
+
return load_file(stdlib_file).force
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
ErrorVal.internal(kind: "reference_error", location: location, operation: "resolving @#{name}", input: name, message: "unresolved reference")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
|
|
188
|
+
def resolve_path(relpath, dir)
|
|
189
|
+
load_file(File.expand_path(relpath + ".fsn", dir)).force
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ---- Expression evaluation -------------------------------------------
|
|
193
|
+
def eval_expr(node, env)
|
|
194
|
+
case node
|
|
195
|
+
when Expression::Lit then node.value
|
|
196
|
+
when Expression::ErrLit
|
|
197
|
+
if node.payload.nil?
|
|
198
|
+
# Bare `!` means `!null`
|
|
199
|
+
ErrorVal.new(NULL)
|
|
200
|
+
else
|
|
201
|
+
payload = eval_expr(node.payload, env)
|
|
202
|
+
|
|
203
|
+
if payload.is_a?(ErrorVal)
|
|
204
|
+
# No nested errors. Propagate inner error.
|
|
205
|
+
payload
|
|
206
|
+
else
|
|
207
|
+
ErrorVal.new(payload)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
when Expression::Ident
|
|
211
|
+
value = env.lookup(node.name)
|
|
212
|
+
|
|
213
|
+
if value == :__unbound__
|
|
214
|
+
ErrorVal.internal(kind: "binding_error", location: code_location(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
|
|
215
|
+
else
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
when Expression::FileRef
|
|
219
|
+
dir = env.lookup("__dir__")
|
|
220
|
+
dir = Dir.pwd if dir == :__unbound__
|
|
221
|
+
case node.variety
|
|
222
|
+
when :self
|
|
223
|
+
# Bare `@` is the current file. NOTE: inline (`-e`) programs have no
|
|
224
|
+
# current file, so `@` is unresolvable there today — but it *should*
|
|
225
|
+
# refer to the whole inline program (tracked as a gap).
|
|
226
|
+
file = env.lookup("__file__")
|
|
227
|
+
|
|
228
|
+
if file == :__unbound__
|
|
229
|
+
ErrorVal.internal(kind: "reference_error", location: code_location(env), operation: "resolving @", input: NULL, message: "no current file for self-reference")
|
|
230
|
+
else
|
|
231
|
+
load_file(file).force
|
|
232
|
+
end
|
|
233
|
+
when :name
|
|
234
|
+
resolve_name(node.path, dir, code_location(env))
|
|
235
|
+
else # :path
|
|
236
|
+
resolve_path(node.path, dir)
|
|
237
|
+
end
|
|
238
|
+
when Expression::ArrLit then eval_array(node, env)
|
|
239
|
+
when Expression::ObjLit then eval_object(node, env)
|
|
240
|
+
when Expression::FuncLit then Func.new(node.clauses, env)
|
|
241
|
+
when Expression::Pipe then eval_pipe(node, env)
|
|
242
|
+
when Expression::Member then eval_member(node, env)
|
|
243
|
+
when Expression::Index then eval_index(node, env)
|
|
244
|
+
else
|
|
245
|
+
raise Unreachable, "Unknown AST node #{node.class}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Array/object literals propagate any error encountered during construction.
|
|
250
|
+
# Errors are not first-class: at any point during execution there is either
|
|
251
|
+
# a value or an error in motion, never both.
|
|
252
|
+
def eval_array(node, env)
|
|
253
|
+
out = []
|
|
254
|
+
|
|
255
|
+
node.items.each do |item|
|
|
256
|
+
value = eval_expr(item.value, env)
|
|
257
|
+
|
|
258
|
+
if value.is_a?(ErrorVal)
|
|
259
|
+
# Propagate errors
|
|
260
|
+
return value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
case item
|
|
264
|
+
when ArrayItem
|
|
265
|
+
out.append(value)
|
|
266
|
+
when ArraySpread
|
|
267
|
+
if value.is_a?(Array)
|
|
268
|
+
out.concat(value)
|
|
269
|
+
else
|
|
270
|
+
return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "[...] array spread", input: value, message: "expected an array")
|
|
271
|
+
end
|
|
272
|
+
else
|
|
273
|
+
raise Unreachable, "Unknown array item #{item.class}"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
out
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def eval_object(node, env)
|
|
281
|
+
out = {}
|
|
282
|
+
|
|
283
|
+
node.pairs.each do |pair|
|
|
284
|
+
value = eval_expr(pair.value, env)
|
|
285
|
+
|
|
286
|
+
if value.is_a?(ErrorVal)
|
|
287
|
+
# Propagate errors
|
|
288
|
+
return value
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
case pair
|
|
292
|
+
when KeyValuePair
|
|
293
|
+
out[pair.key] = value
|
|
294
|
+
when ObjectSpread
|
|
295
|
+
if value.is_a?(Hash)
|
|
296
|
+
out.merge!(value)
|
|
297
|
+
else
|
|
298
|
+
return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "{...} object spread", input: value, message: "expected an object")
|
|
299
|
+
end
|
|
300
|
+
else
|
|
301
|
+
raise Unreachable, "Unknown object pair #{pair.class}"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
out
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def eval_pipe(node, env)
|
|
309
|
+
value = eval_expr(node.left, env)
|
|
310
|
+
function = eval_expr(node.right, env)
|
|
311
|
+
apply(function, value, code_location(env))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def eval_member(node, env)
|
|
315
|
+
obj = eval_expr(node.obj, env)
|
|
316
|
+
|
|
317
|
+
if obj.is_a?(ErrorVal)
|
|
318
|
+
# Propagate errors
|
|
319
|
+
return obj
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
loc = code_location(env)
|
|
323
|
+
unless obj.is_a?(Hash)
|
|
324
|
+
return ErrorVal.internal(kind: "type_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "expected an object")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
unless obj.key?(node.key)
|
|
328
|
+
return ErrorVal.internal(kind: "access_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "missing key")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
obj[node.key]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def eval_index(node, env)
|
|
335
|
+
obj = eval_expr(node.obj, env)
|
|
336
|
+
|
|
337
|
+
if obj.is_a?(ErrorVal)
|
|
338
|
+
# Propagate errors
|
|
339
|
+
return obj
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
idx = eval_expr(node.idx, env)
|
|
343
|
+
|
|
344
|
+
if idx.is_a?(ErrorVal)
|
|
345
|
+
# Propagate errors
|
|
346
|
+
return idx
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
loc = code_location(env)
|
|
350
|
+
if obj.is_a?(Array) && idx.is_a?(Integer)
|
|
351
|
+
i = idx >= 0 ? idx : obj.length + idx
|
|
352
|
+
if i >= 0 && i < obj.length
|
|
353
|
+
obj[i]
|
|
354
|
+
else
|
|
355
|
+
ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx}]", input: [obj, idx], message: "index out of range")
|
|
356
|
+
end
|
|
357
|
+
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
358
|
+
if obj.key?(idx)
|
|
359
|
+
obj[idx]
|
|
360
|
+
else
|
|
361
|
+
ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx.inspect}]", input: [obj, idx], message: "missing key")
|
|
362
|
+
end
|
|
363
|
+
else
|
|
364
|
+
ErrorVal.internal(kind: "type_error", location: loc, operation: "[index]", input: [obj, idx], message: "bad index type")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# ---- Application & matching ------------------------------------------
|
|
369
|
+
# `location` is the "code X" where the `|` lives, used if `f` is not a
|
|
370
|
+
# function. It defaults to "interpreter" for apply calls with no code context
|
|
371
|
+
# (e.g. the CLI applying the whole program).
|
|
372
|
+
def apply(f, v, location = "interpreter")
|
|
373
|
+
if f.is_a?(ErrorVal)
|
|
374
|
+
# Propagate errors
|
|
375
|
+
return f
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
if f.is_a?(NativeFunc)
|
|
379
|
+
if v.is_a?(ErrorVal)
|
|
380
|
+
# Uniform propagation: built-ins never receive errors as inputs.
|
|
381
|
+
return v
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Safety net: a builtin that raises a Ruby error (e.g. a domain error)
|
|
385
|
+
# becomes a payloaded error rather than a raw backtrace on stderr.
|
|
386
|
+
begin
|
|
387
|
+
f.fn.call(v)
|
|
388
|
+
rescue StandardError => err
|
|
389
|
+
kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError)) ? "math_error" : "type_error"
|
|
390
|
+
ErrorVal.internal(kind: kind, location: "builtin #{f.name}", operation: f.name, input: v, message: err.message)
|
|
391
|
+
end
|
|
392
|
+
elsif f.is_a?(Func)
|
|
393
|
+
f.clauses.each do |clause|
|
|
394
|
+
# Bindings are inserted directly into a fresh child env as the pattern
|
|
395
|
+
# matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
|
|
396
|
+
# convert to a binding_error here. A failed/abandoned clause just drops
|
|
397
|
+
# its env, so partial bindings never leak.
|
|
398
|
+
clause_env = f.env.child
|
|
399
|
+
m = begin
|
|
400
|
+
match(clause.pattern, v, clause_env)
|
|
401
|
+
rescue Env::DuplicateBinding => e
|
|
402
|
+
return ErrorVal.internal(kind: "binding_error", location: code_location(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
if m.is_a?(ErrorVal)
|
|
406
|
+
# A `?` predicate raised an error during matching: bubble it up as the
|
|
407
|
+
# function's return value (no further clauses are tried).
|
|
408
|
+
return m
|
|
409
|
+
elsif m
|
|
410
|
+
# Successful match
|
|
411
|
+
return eval_expr(clause.body, clause_env)
|
|
412
|
+
else
|
|
413
|
+
# Try next pattern
|
|
414
|
+
next
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
# No clause matched. If the input was an error, it keeps propagating
|
|
418
|
+
# (an unmatched error must never be silently swallowed). Otherwise the
|
|
419
|
+
# lenient default is `null`.
|
|
420
|
+
v.is_a?(ErrorVal) ? v : NULL
|
|
421
|
+
else
|
|
422
|
+
ErrorVal.internal(kind: "type_error", location: location, operation: "|", input: [v, f], message: "applied a non-function")
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Run a guard predicate against the matched value. The predicate is a `|`
|
|
427
|
+
# pipeline of functions; the value enters at the leftmost stage and the result
|
|
428
|
+
# flows through each stage, so `a ? b | c` evaluates `a | b | c`. A non-pipe
|
|
429
|
+
# predicate is just the single-stage case. #apply propagates any ErrorVal in
|
|
430
|
+
# either the function or the threaded value position.
|
|
431
|
+
def apply_predicate(pred_expr, value, env)
|
|
432
|
+
if pred_expr.is_a?(Expression::Pipe)
|
|
433
|
+
upstream = apply_predicate(pred_expr.left, value, env)
|
|
434
|
+
apply(eval_expr(pred_expr.right, env), upstream, code_location(env))
|
|
435
|
+
else
|
|
436
|
+
apply(eval_expr(pred_expr, env), value, code_location(env))
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Binds matched sub-values into `env` as it goes. Returns true (match),
|
|
441
|
+
# false (no match), or an ErrorVal (predicate errored). A duplicate binder
|
|
442
|
+
# raises Env::DuplicateBinding, caught in #apply.
|
|
443
|
+
def match(pattern, value, env)
|
|
444
|
+
case pattern
|
|
445
|
+
when Pattern::PLit
|
|
446
|
+
deep_equal?(pattern.value, value)
|
|
447
|
+
when Pattern::PErr
|
|
448
|
+
if value.is_a?(ErrorVal)
|
|
449
|
+
# The pattern.inner is always a non-`!` pattern (ensured by the parser)
|
|
450
|
+
match(pattern.inner, value.payload, env)
|
|
451
|
+
else
|
|
452
|
+
false
|
|
453
|
+
end
|
|
454
|
+
when Pattern::PWild
|
|
455
|
+
# `_` matches anything EXCEPT an error value.
|
|
456
|
+
!value.is_a?(ErrorVal)
|
|
457
|
+
when Pattern::PBind
|
|
458
|
+
if value.is_a?(ErrorVal)
|
|
459
|
+
# binders never capture an error
|
|
460
|
+
false
|
|
461
|
+
else
|
|
462
|
+
env.bind(pattern.name, value)
|
|
463
|
+
true
|
|
464
|
+
end
|
|
465
|
+
when Pattern::PArr
|
|
466
|
+
match_array(pattern, value, env)
|
|
467
|
+
when Pattern::PObj
|
|
468
|
+
match_object(pattern, value, env)
|
|
469
|
+
when Pattern::PGuard
|
|
470
|
+
inner_res = match(pattern.inner, value, env)
|
|
471
|
+
if !inner_res
|
|
472
|
+
# The inner pattern didn't match
|
|
473
|
+
false
|
|
474
|
+
elsif inner_res.is_a?(ErrorVal)
|
|
475
|
+
# The inner pattern produced an error
|
|
476
|
+
inner_res
|
|
477
|
+
else
|
|
478
|
+
# The predicate evaluates in the clause's lexical env — `env.parent`, not
|
|
479
|
+
# `env` — so it cannot see the pattern's own binders (including the one it
|
|
480
|
+
# refines). `env` is the clause env created in #apply, threaded through
|
|
481
|
+
# matching unchanged, so its parent is always that lexical env.
|
|
482
|
+
lexical_env = env.parent
|
|
483
|
+
|
|
484
|
+
# The predicate is a pipeline fed the matched value: `a ? b | c` tests
|
|
485
|
+
# `a | b | c`. The value reaching this PGuard is already correct, since
|
|
486
|
+
# `!pat ? pred` parses as PErr(PGuard(pat, pred)) — by now it is the
|
|
487
|
+
# payload. #apply_predicate threads it through each `|` stage.
|
|
488
|
+
predicate_result = apply_predicate(pattern.pred_expr, value, lexical_env)
|
|
489
|
+
if predicate_result.is_a?(ErrorVal)
|
|
490
|
+
# An unresolved @-reference, or an error raised while applying the
|
|
491
|
+
# predicate, becomes the clause's result.
|
|
492
|
+
return predicate_result
|
|
493
|
+
else
|
|
494
|
+
# Ruby-style truthiness: the clause matches unless the predicate
|
|
495
|
+
# yields `false` or `null`.
|
|
496
|
+
truthy?(predicate_result)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
else
|
|
500
|
+
raise Unreachable, "Unknown pattern #{pattern.class}"
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def match_array(pattern, value, env)
|
|
505
|
+
return false unless value.is_a?(Array)
|
|
506
|
+
|
|
507
|
+
items = pattern.items
|
|
508
|
+
rest_index = items.index { |e| e.is_a?(PatternRest) }
|
|
509
|
+
|
|
510
|
+
if rest_index.nil?
|
|
511
|
+
return false unless value.length == items.length
|
|
512
|
+
|
|
513
|
+
items.each_with_index do |item, i|
|
|
514
|
+
r = match(item.pattern, value[i], env)
|
|
515
|
+
return r if r.is_a?(ErrorVal)
|
|
516
|
+
return false unless r
|
|
517
|
+
end
|
|
518
|
+
true
|
|
519
|
+
else
|
|
520
|
+
before = items[0...rest_index]
|
|
521
|
+
after = items[(rest_index + 1)..]
|
|
522
|
+
return false if value.length < before.length + after.length
|
|
523
|
+
before.each_with_index do |item, i|
|
|
524
|
+
r = match(item.pattern, value[i], env)
|
|
525
|
+
return r if r.is_a?(ErrorVal)
|
|
526
|
+
return false unless r
|
|
527
|
+
end
|
|
528
|
+
after.each_with_index do |item, k|
|
|
529
|
+
vi = value.length - after.length + k
|
|
530
|
+
r = match(item.pattern, value[vi], env)
|
|
531
|
+
return r if r.is_a?(ErrorVal)
|
|
532
|
+
return false unless r
|
|
533
|
+
end
|
|
534
|
+
rest_name = items[rest_index].name
|
|
535
|
+
if rest_name
|
|
536
|
+
mid = value[before.length...(value.length - after.length)]
|
|
537
|
+
env.bind(rest_name, mid)
|
|
538
|
+
end
|
|
539
|
+
true
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def match_object(pattern, value, env)
|
|
544
|
+
return false unless value.is_a?(Hash)
|
|
545
|
+
|
|
546
|
+
matched_keys = []
|
|
547
|
+
rest_name = :__none__
|
|
548
|
+
pattern.pairs.each do |pair|
|
|
549
|
+
case pair
|
|
550
|
+
when PatternRest
|
|
551
|
+
rest_name = pair.name # may be nil (ignore) or a string
|
|
552
|
+
when PatternPair
|
|
553
|
+
return false unless value.key?(pair.key)
|
|
554
|
+
r = match(pair.pattern, value[pair.key], env)
|
|
555
|
+
return r if r.is_a?(ErrorVal)
|
|
556
|
+
return false unless r
|
|
557
|
+
matched_keys << pair.key
|
|
558
|
+
else
|
|
559
|
+
raise Unreachable, "Unknown object pattern pair #{pair.class}"
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
case rest_name
|
|
563
|
+
when :__none__
|
|
564
|
+
# No `...rest`: the pattern is closed — a superfluous key means no match.
|
|
565
|
+
return false unless value.size == matched_keys.size
|
|
566
|
+
when nil
|
|
567
|
+
# Bare `...`: extra keys are allowed but bound to nothing.
|
|
568
|
+
else
|
|
569
|
+
env.bind(rest_name, value.reject { |k, _| matched_keys.include?(k) })
|
|
570
|
+
end
|
|
571
|
+
true
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# ---- Equality & helpers ----------------------------------------------
|
|
575
|
+
# Ruby-style truthiness: `false` and `null` are falsey, everything else
|
|
576
|
+
# (numbers, strings, arrays, objects, functions — including `0` and `""`) is
|
|
577
|
+
# truthy. Used by `?` guards and the `@and` / `@or` / `@not` built-ins.
|
|
578
|
+
def truthy?(value)
|
|
579
|
+
value != false && value != NULL
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def deep_equal?(a, b)
|
|
583
|
+
return true if a.equal?(b)
|
|
584
|
+
return false if a.class != b.class
|
|
585
|
+
case a
|
|
586
|
+
when Array
|
|
587
|
+
a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
|
|
588
|
+
when Hash
|
|
589
|
+
a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
|
|
590
|
+
else
|
|
591
|
+
a == b
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|