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,752 @@
|
|
|
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 "pathname"
|
|
22
|
+
|
|
23
|
+
require_relative "ast"
|
|
24
|
+
require_relative "null"
|
|
25
|
+
require_relative "interpreter/error_val"
|
|
26
|
+
require_relative "interpreter/func"
|
|
27
|
+
require_relative "interpreter/native_func"
|
|
28
|
+
require_relative "interpreter/builtins"
|
|
29
|
+
require_relative "interpreter/env"
|
|
30
|
+
require_relative "interpreter/thunk"
|
|
31
|
+
|
|
32
|
+
module Fusion
|
|
33
|
+
class Interpreter
|
|
34
|
+
include AST
|
|
35
|
+
|
|
36
|
+
# The binding-free root the run is built on — computed on demand. Loaded
|
|
37
|
+
# files are isolated against it (see evaluate_file).
|
|
38
|
+
def root_env
|
|
39
|
+
@env.root
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `env` is the run's environment, passed in externally and stored as `@env`.
|
|
43
|
+
# Its `:jail` context confines @-resolution, and its topmost ancestor
|
|
44
|
+
# (`@env.root`) is the binding-free root that loaded files are isolated against.
|
|
45
|
+
# The stdlib always stays reachable.
|
|
46
|
+
def initialize(env, env_vars: nil)
|
|
47
|
+
@stdlib_dir = File.expand_path("../../stdlib", __dir__)
|
|
48
|
+
raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir)
|
|
49
|
+
|
|
50
|
+
@env = env
|
|
51
|
+
@env_vars = env_vars || ENV.to_h
|
|
52
|
+
@file_cache = {} # abspath -> Thunk
|
|
53
|
+
@ast_cache = {} # abspath -> AST
|
|
54
|
+
@builtins = {} # name -> NativeFunc (consulted by @name, not via env)
|
|
55
|
+
Builtins.install(@builtins, self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apply the program to one input behind a safety net: a Ruby-level failure
|
|
59
|
+
# (notably a stack overflow) becomes a payloaded error rather than a raw
|
|
60
|
+
# backtrace, so the stdout/stderr contract always holds. In the stream the
|
|
61
|
+
# error is one record's output and the next line continues.
|
|
62
|
+
def self.safe_apply(function, input, environment)
|
|
63
|
+
safe do
|
|
64
|
+
new(environment).apply(function, input)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Evaluate an expression behind the same per-run safety net as
|
|
69
|
+
# exe/fusion, so a Ruby-level failure becomes a printed payload and the
|
|
70
|
+
# session survives it. A statement carries its expression; a bare
|
|
71
|
+
# expression entry is the expression itself.
|
|
72
|
+
def self.safe_evaluate(expression, environment)
|
|
73
|
+
safe do
|
|
74
|
+
new(environment).evaluate_unit(expression)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.safe
|
|
79
|
+
yield
|
|
80
|
+
rescue Unreachable
|
|
81
|
+
# An interpreter bug. Allowed to surface.
|
|
82
|
+
raise
|
|
83
|
+
rescue StandardError => err
|
|
84
|
+
Interpreter::ErrorVal.from_runtime(
|
|
85
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
86
|
+
input: NULL, message: err.message
|
|
87
|
+
)
|
|
88
|
+
rescue SystemExit
|
|
89
|
+
# Let exit/abort through.
|
|
90
|
+
raise
|
|
91
|
+
rescue SystemStackError
|
|
92
|
+
Interpreter::ErrorVal.from_runtime(
|
|
93
|
+
kind: "limit_error", origin: "interpreter", operation: "running the program",
|
|
94
|
+
input: NULL, message: "stack level too deep"
|
|
95
|
+
)
|
|
96
|
+
rescue Exception => err # rubocop:disable Lint/RescueException
|
|
97
|
+
# Final net: any other escaped Ruby error becomes a payloaded error too.
|
|
98
|
+
Interpreter::ErrorVal.from_runtime(
|
|
99
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
100
|
+
input: NULL, message: err.message
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---- File loading -----------------------------------------------------
|
|
105
|
+
# `Thunk` does cycle-detection and result-memoization.
|
|
106
|
+
def load_file(abspath)
|
|
107
|
+
@file_cache[abspath] ||= Thunk.new { evaluate_file(abspath) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A file path for error payloads: relative to the working directory, so a
|
|
111
|
+
# payload carries no machine-specific absolute prefix (and stays stable when
|
|
112
|
+
# a whole project is moved together).
|
|
113
|
+
def display_path(abspath)
|
|
114
|
+
Pathname.new(abspath).relative_path_from(Dir.pwd).to_s
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
abspath # no relative path exists (e.g. different roots) — keep the absolute
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The error site (`{origin:, file?:}`) for code at `abspath`. stdlib is part
|
|
120
|
+
# of the core language, so its internal filenames are never exposed; only
|
|
121
|
+
# user `code` carries a `file`.
|
|
122
|
+
def file_site(abspath)
|
|
123
|
+
if abspath.start_with?(@stdlib_dir + File::SEPARATOR)
|
|
124
|
+
{ origin: "stdlib" }
|
|
125
|
+
else
|
|
126
|
+
{ origin: "code", file: display_path(abspath) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The error fields `{origin:, file:}` for code being evaluated under `env`.
|
|
131
|
+
def code_site(env)
|
|
132
|
+
f = env.context(:file)
|
|
133
|
+
if f == :__unbound__
|
|
134
|
+
# Inline (`-e`) programs and REPL entries report an "<inline>" file.
|
|
135
|
+
{ origin: "code", file: "<inline>" }
|
|
136
|
+
else
|
|
137
|
+
file_site(f)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The `file` an error here should carry: the innermost *user-code* file on the
|
|
142
|
+
# dynamic call chain. When stdlib code runs, it borrows the user call site that
|
|
143
|
+
# reached it (injected as `:call_site` in #apply); user/inline code is its own
|
|
144
|
+
# file (derived from `code_site`); above any user code, the runtime: "<fusion>".
|
|
145
|
+
def call_site(env)
|
|
146
|
+
injected = env.context(:call_site)
|
|
147
|
+
return injected unless injected == :__unbound__
|
|
148
|
+
|
|
149
|
+
site = code_site(env)
|
|
150
|
+
site[:origin] == "code" ? site[:file] : "<fusion>"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Compute the file's value. Use only within `Thunk` and raise `Thunk::ReadFailure`
|
|
154
|
+
# for unreadable files.
|
|
155
|
+
def evaluate_file(abspath)
|
|
156
|
+
ast = (@ast_cache[abspath] ||= begin
|
|
157
|
+
src = File.read(abspath)
|
|
158
|
+
Parser.parse_file(src, site: file_site(abspath))
|
|
159
|
+
end)
|
|
160
|
+
|
|
161
|
+
if ast.is_a?(ErrorVal) # a parse error (already a payloaded value)
|
|
162
|
+
ast
|
|
163
|
+
else
|
|
164
|
+
env = root_env.child
|
|
165
|
+
env.set_context(:dir, File.dirname(abspath)) # for resolving @-refs
|
|
166
|
+
env.set_context(:file, abspath) # for error sites
|
|
167
|
+
env.set_context(:self, load_file(abspath)) # for `@` self-recursion
|
|
168
|
+
eval_expr(ast, env)
|
|
169
|
+
end
|
|
170
|
+
rescue Errno::ENOENT
|
|
171
|
+
raise Thunk::ReadFailure, "file not found"
|
|
172
|
+
rescue SystemCallError => err # EISDIR, EACCES, ... (file-system access failures)
|
|
173
|
+
# Drop Ruby's "@ io_fread - <path>" tail.
|
|
174
|
+
raise Thunk::ReadFailure, err.message.split(" @ ").first.downcase
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Evaluate a top-level unit that has no file of its own:
|
|
178
|
+
# - inline source (`-e`)
|
|
179
|
+
# - REPL entries
|
|
180
|
+
def evaluate_unit(ast)
|
|
181
|
+
# Evaluate in a child of `@env`, so we don't mutate it. The child inherits
|
|
182
|
+
# `@env`'s bindings (only non-empty in the REPL), `:dir`, and jail.
|
|
183
|
+
unit_env = @env.child
|
|
184
|
+
|
|
185
|
+
thunk = Thunk.new { eval_expr(ast, unit_env) }
|
|
186
|
+
unit_env.set_context(:self, thunk) # for `@` self-recursion
|
|
187
|
+
thunk.force
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
|
|
191
|
+
# `site` is the `{origin:, file:}` of the referencing code; `reference` is its
|
|
192
|
+
# own source text (`"@name"`) — the single `operation` every failure reports.
|
|
193
|
+
def resolve_name(name, dir, site, reference)
|
|
194
|
+
sibling_file = File.expand_path(name + ".fsn", dir)
|
|
195
|
+
if File.exist?(sibling_file)
|
|
196
|
+
return jail_error(site, reference, NULL) unless within_jail?(sibling_file)
|
|
197
|
+
|
|
198
|
+
return load_file(sibling_file).force(operation: reference, input: NULL, site: site)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
resolve_builtin_or_stdlib(name, dir, site, reference)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Resolve "@@": the builtin/stdlib that the referencing file shadows. It is its
|
|
205
|
+
# own name resolved with the sibling step skipped (the sibling is itself), so
|
|
206
|
+
# the file can extend what it overrides. There is no file to take super of in
|
|
207
|
+
# an inline (`-e`) or REPL entry.
|
|
208
|
+
def resolve_super(env, dir, site)
|
|
209
|
+
file = env.context(:file)
|
|
210
|
+
if file == :__unbound__
|
|
211
|
+
return ErrorVal.from_runtime(kind: "reference_error", **site, operation: "@@", input: NULL, message: "no enclosing file")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
resolve_builtin_or_stdlib(File.basename(file, ".fsn"), dir, site, "@@")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The non-sibling tail of @name resolution: builtin (incl. load, ENV) > stdlib > !.
|
|
218
|
+
def resolve_builtin_or_stdlib(name, dir, site, reference)
|
|
219
|
+
if name == "ENV"
|
|
220
|
+
return @env_vars.dup
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if name == "load"
|
|
224
|
+
# @load is a builtin closure capturing the calling file's directory. It
|
|
225
|
+
# loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
|
|
226
|
+
d = dir
|
|
227
|
+
return NativeFunc.new("load", lambda do |v|
|
|
228
|
+
# @load errors carry origin "builtin" and no `file`; #apply stamps the
|
|
229
|
+
# call site (the user file that wrote `| @load`) onto them as `file`.
|
|
230
|
+
site = { origin: "builtin", file: nil }
|
|
231
|
+
|
|
232
|
+
unless v.is_a?(String)
|
|
233
|
+
next ErrorVal.from_runtime(kind: "argument_error", **site, operation: "@load", input: v, expected: ["_ ? @String"])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
target = File.expand_path(v, d)
|
|
237
|
+
|
|
238
|
+
# Check the jail before touching the filesystem, so an out-of-jail
|
|
239
|
+
# path can't be probed for existence.
|
|
240
|
+
next jail_error(site, "@load", v) unless within_jail?(target)
|
|
241
|
+
|
|
242
|
+
unless File.exist?(target)
|
|
243
|
+
next ErrorVal.from_runtime(kind: "reference_error", **site, operation: "@load", input: v, message: "file not found")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
load_file(target).force(operation: "@load", input: v, site: site)
|
|
247
|
+
end)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if @builtins.key?(name)
|
|
251
|
+
return @builtins[name]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
stdlib_file = File.join(@stdlib_dir, name + ".fsn")
|
|
255
|
+
if File.exist?(stdlib_file)
|
|
256
|
+
# The reference reports the user's source text, not the internal stdlib path.
|
|
257
|
+
return load_file(stdlib_file).force(operation: reference, input: NULL, site: site)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
ErrorVal.from_runtime(kind: "reference_error", **site, operation: reference, input: NULL, message: "unresolved reference")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
|
|
264
|
+
# `reference` is the source text (`"@../a"`), the single `operation` reported.
|
|
265
|
+
def resolve_path(relpath, dir, site, reference)
|
|
266
|
+
target = File.expand_path(relpath + ".fsn", dir)
|
|
267
|
+
return jail_error(site, reference, NULL) unless within_jail?(target)
|
|
268
|
+
|
|
269
|
+
load_file(target).force(operation: reference, input: NULL, site: site)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The run's jail (the `:jail` context, an absolute path or nil) confines
|
|
273
|
+
# file-backed @-resolution to its subtree. The stdlib is always reachable (it
|
|
274
|
+
# lives outside any project), and a nil/unset jail means unconfined. Containment
|
|
275
|
+
# is lexical (expand_path normalises `..`) and follows existing symlinks: it
|
|
276
|
+
# confines references, it is not a security sandbox and needs none — Fusion
|
|
277
|
+
# cannot write files, so no symlink can be planted to escape.
|
|
278
|
+
def within_jail?(abspath)
|
|
279
|
+
jail = @env.context(:jail)
|
|
280
|
+
return true if jail.nil? || jail == :__unbound__
|
|
281
|
+
return true if inside?(abspath, @stdlib_dir)
|
|
282
|
+
|
|
283
|
+
inside?(abspath, jail)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def inside?(abspath, root)
|
|
287
|
+
root = root.chomp(File::SEPARATOR)
|
|
288
|
+
abspath == root || abspath.start_with?(root + File::SEPARATOR)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def jail_error(site, operation, input)
|
|
292
|
+
ErrorVal.from_runtime(kind: "reference_error", **site, operation: operation, input: input, message: "outside the jail")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# ---- Expression evaluation -------------------------------------------
|
|
296
|
+
def eval_expr(node, env)
|
|
297
|
+
case node
|
|
298
|
+
when Expression::Lit then node.value
|
|
299
|
+
when Expression::ErrLit
|
|
300
|
+
# Mark errors from within the stdlib as runtime-produced.
|
|
301
|
+
runtime = code_site(env)[:origin] == "stdlib"
|
|
302
|
+
|
|
303
|
+
if node.payload.nil?
|
|
304
|
+
# Bare `!` means `!null`
|
|
305
|
+
ErrorVal.new(NULL, runtime: runtime)
|
|
306
|
+
else
|
|
307
|
+
payload = eval_expr(node.payload, env)
|
|
308
|
+
|
|
309
|
+
if payload.is_a?(ErrorVal)
|
|
310
|
+
# No nested errors. Propagate inner error.
|
|
311
|
+
payload
|
|
312
|
+
else
|
|
313
|
+
ErrorVal.new(payload, runtime: runtime)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
when Expression::Ident
|
|
317
|
+
value = env.lookup(node.name)
|
|
318
|
+
|
|
319
|
+
if value == :__unbound__
|
|
320
|
+
ErrorVal.from_runtime(kind: "binding_error", **code_site(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
|
|
321
|
+
else
|
|
322
|
+
value
|
|
323
|
+
end
|
|
324
|
+
when Expression::FileRef
|
|
325
|
+
dir = env.context(:dir)
|
|
326
|
+
dir = Dir.pwd if dir == :__unbound__
|
|
327
|
+
case node.variety
|
|
328
|
+
when :self
|
|
329
|
+
# Bare `@` is the value of the current top-level unit: a file, or an inline (`-e`)/REPL entry.
|
|
330
|
+
self_thunk = env.context(:self)
|
|
331
|
+
|
|
332
|
+
if self_thunk == :__unbound__
|
|
333
|
+
raise Unreachable, "bare @ evaluated outside a top-level unit"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
self_thunk.force(operation: "@", input: NULL, site: code_site(env))
|
|
337
|
+
when :super
|
|
338
|
+
resolve_super(env, dir, code_site(env))
|
|
339
|
+
when :super_name
|
|
340
|
+
# `@@name`: the stable builtin/stdlib `name`, skipping any sibling shadow.
|
|
341
|
+
resolve_builtin_or_stdlib(node.path, dir, code_site(env), "@@#{node.path}")
|
|
342
|
+
when :name
|
|
343
|
+
resolve_name(node.path, dir, code_site(env), "@#{node.path}")
|
|
344
|
+
else # :path
|
|
345
|
+
resolve_path(node.path, dir, code_site(env), "@#{node.path}")
|
|
346
|
+
end
|
|
347
|
+
when Expression::ArrLit then eval_array(node, env)
|
|
348
|
+
when Expression::ObjLit then eval_object(node, env)
|
|
349
|
+
when Expression::FuncLit then Func.new(node.clauses, env)
|
|
350
|
+
when Expression::Pipe then eval_pipe(node, env)
|
|
351
|
+
when Expression::Member then eval_member(node, env)
|
|
352
|
+
when Expression::Index then eval_index(node, env)
|
|
353
|
+
when Expression::IndexSet then eval_index_set(node, env)
|
|
354
|
+
else
|
|
355
|
+
raise Unreachable, "Unknown AST node #{node.class}"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Array/object literals propagate any error encountered during construction.
|
|
360
|
+
# Errors are not first-class: at any point during execution there is either
|
|
361
|
+
# a value or an error in motion, never both.
|
|
362
|
+
def eval_array(node, env)
|
|
363
|
+
out = []
|
|
364
|
+
|
|
365
|
+
node.items.each do |item|
|
|
366
|
+
value = eval_expr(item.value, env)
|
|
367
|
+
|
|
368
|
+
if value.is_a?(ErrorVal)
|
|
369
|
+
# Propagate errors
|
|
370
|
+
return value
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
case item
|
|
374
|
+
when ArrayItem
|
|
375
|
+
out.append(value)
|
|
376
|
+
when ArraySpread
|
|
377
|
+
if value.is_a?(Array)
|
|
378
|
+
out.concat(value)
|
|
379
|
+
else
|
|
380
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "[...] array spread", input: value, expected: ["_ ? @Array"])
|
|
381
|
+
end
|
|
382
|
+
else
|
|
383
|
+
raise Unreachable, "Unknown array item #{item.class}"
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
out
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def eval_object(node, env)
|
|
391
|
+
out = {}
|
|
392
|
+
|
|
393
|
+
node.pairs.each do |pair|
|
|
394
|
+
value = eval_expr(pair.value, env)
|
|
395
|
+
|
|
396
|
+
if value.is_a?(ErrorVal)
|
|
397
|
+
# Propagate errors
|
|
398
|
+
return value
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
case pair
|
|
402
|
+
when KeyValuePair
|
|
403
|
+
out[pair.key] = value
|
|
404
|
+
when ObjectSpread
|
|
405
|
+
if value.is_a?(Hash)
|
|
406
|
+
out.merge!(value)
|
|
407
|
+
else
|
|
408
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "{...} object spread", input: value, expected: ["_ ? @Object"])
|
|
409
|
+
end
|
|
410
|
+
else
|
|
411
|
+
raise Unreachable, "Unknown object pair #{pair.class}"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
out
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def eval_pipe(node, env)
|
|
419
|
+
value = eval_expr(node.left, env)
|
|
420
|
+
function = eval_expr(node.right, env)
|
|
421
|
+
apply(function, value, call_site(env))
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def eval_member(node, env)
|
|
425
|
+
obj = eval_expr(node.obj, env)
|
|
426
|
+
|
|
427
|
+
if obj.is_a?(ErrorVal)
|
|
428
|
+
# Propagate errors
|
|
429
|
+
return obj
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
site = code_site(env)
|
|
433
|
+
unless obj.is_a?(Hash)
|
|
434
|
+
return ErrorVal.from_runtime(kind: "argument_error", **site, operation: ".#{node.key}", input: obj, expected: ["_ ? @Object"])
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
unless obj.key?(node.key)
|
|
438
|
+
return ErrorVal.from_runtime(kind: "access_error", **site, operation: ".#{node.key}", input: obj, message: "missing key")
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
obj[node.key]
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def eval_index(node, env)
|
|
445
|
+
obj = eval_expr(node.obj, env)
|
|
446
|
+
|
|
447
|
+
if obj.is_a?(ErrorVal)
|
|
448
|
+
# Propagate errors
|
|
449
|
+
return obj
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
idx = eval_expr(node.idx, env)
|
|
453
|
+
|
|
454
|
+
if idx.is_a?(ErrorVal)
|
|
455
|
+
# Propagate errors
|
|
456
|
+
return idx
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
site = code_site(env)
|
|
460
|
+
if obj.is_a?(Array) && idx.is_a?(Integer)
|
|
461
|
+
i = idx >= 0 ? idx : obj.length + idx
|
|
462
|
+
if i >= 0 && i < obj.length
|
|
463
|
+
obj[i]
|
|
464
|
+
else
|
|
465
|
+
ErrorVal.from_runtime(kind: "access_error", **site, operation: "[]", input: [obj, idx], message: "index out of range")
|
|
466
|
+
end
|
|
467
|
+
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
468
|
+
if obj.key?(idx)
|
|
469
|
+
obj[idx]
|
|
470
|
+
else
|
|
471
|
+
ErrorVal.from_runtime(kind: "access_error", **site, operation: "[]", input: [obj, idx], message: "missing key")
|
|
472
|
+
end
|
|
473
|
+
else
|
|
474
|
+
ErrorVal.from_runtime(kind: "argument_error", **site, operation: "[]", input: [obj, idx], expected: ["[_ ? @Array, _ ? @Integer]", "[_ ? @Object, _ ? @String]"])
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# `obj[idx = value]` — returns a new array/object with one entry set (the setter
|
|
479
|
+
# counterpart of eval_index). An array index must already exist (arrays are not
|
|
480
|
+
# extended; negative indices count from the end); an object key may be new. The
|
|
481
|
+
# original `obj` is unchanged.
|
|
482
|
+
def eval_index_set(node, env)
|
|
483
|
+
obj = eval_expr(node.obj, env)
|
|
484
|
+
return obj if obj.is_a?(ErrorVal)
|
|
485
|
+
|
|
486
|
+
idx = eval_expr(node.idx, env)
|
|
487
|
+
return idx if idx.is_a?(ErrorVal)
|
|
488
|
+
|
|
489
|
+
value = eval_expr(node.value, env)
|
|
490
|
+
return value if value.is_a?(ErrorVal)
|
|
491
|
+
|
|
492
|
+
site = code_site(env)
|
|
493
|
+
if obj.is_a?(Array) && idx.is_a?(Integer)
|
|
494
|
+
i = idx >= 0 ? idx : obj.length + idx
|
|
495
|
+
if i >= 0 && i < obj.length
|
|
496
|
+
obj.dup.tap { |copy| copy[i] = value }
|
|
497
|
+
else
|
|
498
|
+
ErrorVal.from_runtime(kind: "access_error", **site, operation: "[=]", input: [obj, idx, value], message: "index out of range")
|
|
499
|
+
end
|
|
500
|
+
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
501
|
+
obj.merge(idx => value)
|
|
502
|
+
else
|
|
503
|
+
ErrorVal.from_runtime(kind: "argument_error", **site, operation: "[=]", input: [obj, idx, value], expected: ["[_ ? @Array, _ ? @Integer, _]", "[_ ? @Object, _ ? @String, _]"])
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# ---- Application & matching ------------------------------------------
|
|
508
|
+
# `call_site` is the innermost user-code file the application runs for (see
|
|
509
|
+
# #call_site): a built-in/stdlib error reports it as its `file`, and a stdlib
|
|
510
|
+
# function passes it on to the operations it calls. It defaults to the runtime
|
|
511
|
+
# ("<fusion>") for an apply with no user-code caller (e.g. the CLI applying the
|
|
512
|
+
# whole program directly to a value).
|
|
513
|
+
def apply(f, v, call_site = "<fusion>")
|
|
514
|
+
result = dispatch_apply(f, v, call_site)
|
|
515
|
+
# The interpreter owns the call-site `file` of a standardized builtin/stdlib
|
|
516
|
+
# error (the call site is its knowledge, not the stdlib's): stamp it here, at
|
|
517
|
+
# the apply that produced the error. An error keeps the file from its
|
|
518
|
+
# innermost apply, so outer applies leave it untouched (see #with_call_site).
|
|
519
|
+
result.is_a?(ErrorVal) ? result.with_call_site(call_site) : result
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def dispatch_apply(f, v, call_site)
|
|
523
|
+
if f.is_a?(ErrorVal)
|
|
524
|
+
# Propagate errors
|
|
525
|
+
return f
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
if f.is_a?(NativeFunc)
|
|
529
|
+
if v.is_a?(ErrorVal)
|
|
530
|
+
# Uniform propagation: built-ins never receive errors as inputs.
|
|
531
|
+
return v
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Safety net: a builtin that raises a Ruby error (e.g. a domain error)
|
|
535
|
+
# becomes a payloaded error rather than a raw backtrace on stderr.
|
|
536
|
+
begin
|
|
537
|
+
f.fn.call(v)
|
|
538
|
+
rescue StandardError => err
|
|
539
|
+
# TODO: move math errors into the builtins. This should become a safety net for unpredicted errors.
|
|
540
|
+
kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError) || err.is_a?(Math::DomainError)) ? "math_error" : "internal_error"
|
|
541
|
+
ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{f.name}", input: v, message: err.message)
|
|
542
|
+
end
|
|
543
|
+
elsif f.is_a?(Func)
|
|
544
|
+
# Stdlib code has no user file of its own: errors inside it (and in the
|
|
545
|
+
# built-ins it calls) report the user `call_site` that reached it. User and
|
|
546
|
+
# inline functions are their own call site (derived lexically from their env).
|
|
547
|
+
body_call_site = (code_site(f.env)[:origin] == "stdlib") ? call_site : nil
|
|
548
|
+
|
|
549
|
+
f.clauses.each do |clause|
|
|
550
|
+
# Bindings are inserted directly into a fresh child env as the pattern
|
|
551
|
+
# matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
|
|
552
|
+
# convert to a binding_error here. A failed/abandoned clause just drops
|
|
553
|
+
# its env, so partial bindings never leak.
|
|
554
|
+
clause_env = f.env.child
|
|
555
|
+
clause_env.set_context(:call_site, body_call_site) if body_call_site
|
|
556
|
+
m = begin
|
|
557
|
+
match(clause.pattern, v, clause_env)
|
|
558
|
+
rescue Env::DuplicateBinding => e
|
|
559
|
+
return ErrorVal.from_runtime(kind: "binding_error", **code_site(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
if m.is_a?(ErrorVal)
|
|
563
|
+
# A `?` predicate raised an error during matching: bubble it up as the
|
|
564
|
+
# function's return value (no further clauses are tried).
|
|
565
|
+
return m
|
|
566
|
+
elsif m
|
|
567
|
+
# Successful match
|
|
568
|
+
return eval_expr(clause.body, clause_env)
|
|
569
|
+
else
|
|
570
|
+
# Try next pattern
|
|
571
|
+
next
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
# No clause matched. If the input was an error, it keeps propagating
|
|
575
|
+
# (an unmatched error must never be silently swallowed). Otherwise the
|
|
576
|
+
# lenient default is `null`.
|
|
577
|
+
v.is_a?(ErrorVal) ? v : NULL
|
|
578
|
+
else
|
|
579
|
+
ErrorVal.from_runtime(kind: "argument_error", origin: "code", file: call_site, operation: "|", input: [v, f], expected: ["[_, _ ? @Function]"])
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Run a guard predicate against the matched value. The predicate is a `|`
|
|
584
|
+
# pipeline of functions; the value enters at the leftmost stage and the result
|
|
585
|
+
# flows through each stage, so `a ? b | c` evaluates `a | b | c`. A non-pipe
|
|
586
|
+
# predicate is just the single-stage case. #apply propagates any ErrorVal in
|
|
587
|
+
# either the function or the threaded value position.
|
|
588
|
+
def apply_predicate(pred_expr, value, env)
|
|
589
|
+
if pred_expr.is_a?(Expression::Pipe)
|
|
590
|
+
upstream = apply_predicate(pred_expr.left, value, env)
|
|
591
|
+
apply(eval_expr(pred_expr.right, env), upstream, call_site(env))
|
|
592
|
+
else
|
|
593
|
+
apply(eval_expr(pred_expr, env), value, call_site(env))
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Binds matched sub-values into `env` as it goes. Returns true (match),
|
|
598
|
+
# false (no match), or an ErrorVal (predicate errored). A duplicate binder
|
|
599
|
+
# raises Env::DuplicateBinding, caught in #apply.
|
|
600
|
+
def match(pattern, value, env)
|
|
601
|
+
case pattern
|
|
602
|
+
when Pattern::PLit
|
|
603
|
+
deep_equal?(pattern.value, value)
|
|
604
|
+
when Pattern::PErr
|
|
605
|
+
if value.is_a?(ErrorVal)
|
|
606
|
+
# The pattern.inner is always a non-`!` pattern (ensured by the parser)
|
|
607
|
+
match(pattern.inner, value.payload, env)
|
|
608
|
+
else
|
|
609
|
+
false
|
|
610
|
+
end
|
|
611
|
+
when Pattern::PWild
|
|
612
|
+
# `_` matches anything EXCEPT an error value.
|
|
613
|
+
!value.is_a?(ErrorVal)
|
|
614
|
+
when Pattern::PBind
|
|
615
|
+
if value.is_a?(ErrorVal)
|
|
616
|
+
# binders never capture an error
|
|
617
|
+
false
|
|
618
|
+
else
|
|
619
|
+
env.bind(pattern.name, value)
|
|
620
|
+
true
|
|
621
|
+
end
|
|
622
|
+
when Pattern::PArr
|
|
623
|
+
match_array(pattern, value, env)
|
|
624
|
+
when Pattern::PObj
|
|
625
|
+
match_object(pattern, value, env)
|
|
626
|
+
when Pattern::PGuard
|
|
627
|
+
inner_res = match(pattern.inner, value, env)
|
|
628
|
+
if !inner_res
|
|
629
|
+
# The inner pattern didn't match
|
|
630
|
+
false
|
|
631
|
+
elsif inner_res.is_a?(ErrorVal)
|
|
632
|
+
# The inner pattern produced an error
|
|
633
|
+
inner_res
|
|
634
|
+
else
|
|
635
|
+
# The predicate evaluates in the clause's lexical env — `env.parent`, not
|
|
636
|
+
# `env` — so it cannot see the pattern's own binders (including the one it
|
|
637
|
+
# refines). `env` is the clause env created in #apply, threaded through
|
|
638
|
+
# matching unchanged, so its parent is always that lexical env.
|
|
639
|
+
lexical_env = env.parent
|
|
640
|
+
|
|
641
|
+
# The predicate is a pipeline fed the matched value: `a ? b | c` tests
|
|
642
|
+
# `a | b | c`. The value reaching this PGuard is already correct, since
|
|
643
|
+
# `!pat ? pred` parses as PErr(PGuard(pat, pred)) — by now it is the
|
|
644
|
+
# payload. #apply_predicate threads it through each `|` stage.
|
|
645
|
+
predicate_result = apply_predicate(pattern.pred_expr, value, lexical_env)
|
|
646
|
+
if predicate_result.is_a?(ErrorVal)
|
|
647
|
+
# An unresolved @-reference, or an error raised while applying the
|
|
648
|
+
# predicate, becomes the clause's result.
|
|
649
|
+
return predicate_result
|
|
650
|
+
else
|
|
651
|
+
# Ruby-style truthiness: the clause matches unless the predicate
|
|
652
|
+
# yields `false` or `null`.
|
|
653
|
+
truthy?(predicate_result)
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
else
|
|
657
|
+
raise Unreachable, "Unknown pattern #{pattern.class}"
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def match_array(pattern, value, env)
|
|
662
|
+
return false unless value.is_a?(Array)
|
|
663
|
+
|
|
664
|
+
items = pattern.items
|
|
665
|
+
rest_index = items.index { |e| e.is_a?(PatternRest) }
|
|
666
|
+
|
|
667
|
+
if rest_index.nil?
|
|
668
|
+
return false unless value.length == items.length
|
|
669
|
+
|
|
670
|
+
items.each_with_index do |item, i|
|
|
671
|
+
r = match(item.pattern, value[i], env)
|
|
672
|
+
return r if r.is_a?(ErrorVal)
|
|
673
|
+
return false unless r
|
|
674
|
+
end
|
|
675
|
+
true
|
|
676
|
+
else
|
|
677
|
+
before = items[0...rest_index]
|
|
678
|
+
after = items[(rest_index + 1)..]
|
|
679
|
+
return false if value.length < before.length + after.length
|
|
680
|
+
before.each_with_index do |item, i|
|
|
681
|
+
r = match(item.pattern, value[i], env)
|
|
682
|
+
return r if r.is_a?(ErrorVal)
|
|
683
|
+
return false unless r
|
|
684
|
+
end
|
|
685
|
+
after.each_with_index do |item, k|
|
|
686
|
+
vi = value.length - after.length + k
|
|
687
|
+
r = match(item.pattern, value[vi], env)
|
|
688
|
+
return r if r.is_a?(ErrorVal)
|
|
689
|
+
return false unless r
|
|
690
|
+
end
|
|
691
|
+
rest_name = items[rest_index].name
|
|
692
|
+
if rest_name
|
|
693
|
+
mid = value[before.length...(value.length - after.length)]
|
|
694
|
+
env.bind(rest_name, mid)
|
|
695
|
+
end
|
|
696
|
+
true
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def match_object(pattern, value, env)
|
|
701
|
+
return false unless value.is_a?(Hash)
|
|
702
|
+
|
|
703
|
+
matched_keys = []
|
|
704
|
+
rest_name = :__none__
|
|
705
|
+
pattern.pairs.each do |pair|
|
|
706
|
+
case pair
|
|
707
|
+
when PatternRest
|
|
708
|
+
rest_name = pair.name # may be nil (ignore) or a string
|
|
709
|
+
when PatternPair
|
|
710
|
+
return false unless value.key?(pair.key)
|
|
711
|
+
r = match(pair.pattern, value[pair.key], env)
|
|
712
|
+
return r if r.is_a?(ErrorVal)
|
|
713
|
+
return false unless r
|
|
714
|
+
matched_keys << pair.key
|
|
715
|
+
else
|
|
716
|
+
raise Unreachable, "Unknown object pattern pair #{pair.class}"
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
case rest_name
|
|
720
|
+
when :__none__
|
|
721
|
+
# No `...rest`: the pattern is closed — a superfluous key means no match.
|
|
722
|
+
return false unless value.size == matched_keys.size
|
|
723
|
+
when nil
|
|
724
|
+
# Bare `...`: extra keys are allowed but bound to nothing.
|
|
725
|
+
else
|
|
726
|
+
env.bind(rest_name, value.reject { |k, _| matched_keys.include?(k) })
|
|
727
|
+
end
|
|
728
|
+
true
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# ---- Equality & helpers ----------------------------------------------
|
|
732
|
+
# Ruby-style truthiness: `false` and `null` are falsey, everything else
|
|
733
|
+
# (numbers, strings, arrays, objects, functions — including `0` and `""`) is
|
|
734
|
+
# truthy. Used by `?` guards and the `@and` / `@or` / `@not` built-ins.
|
|
735
|
+
def truthy?(value)
|
|
736
|
+
value != false && value != NULL
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def deep_equal?(a, b)
|
|
740
|
+
return true if a.equal?(b)
|
|
741
|
+
return false if a.class != b.class
|
|
742
|
+
case a
|
|
743
|
+
when Array
|
|
744
|
+
a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
|
|
745
|
+
when Hash
|
|
746
|
+
a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
|
|
747
|
+
else
|
|
748
|
+
a == b
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|