fusion-lang 0.0.1.alpha2 → 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 +9 -8
- data/docs/lang/design.md +240 -51
- data/docs/lang/implementation.md +238 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +60 -15
- data/docs/user/reference.md +356 -142
- data/docs/user/tutorial.md +21 -19
- data/examples/double.fsn +1 -1
- data/examples/factorial.fsn +2 -2
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/json_test.fsn +4 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +10 -10
- data/lib/fusion/ast.rb +2 -1
- data/lib/fusion/cli/decoder.rb +10 -5
- data/lib/fusion/cli/options.rb +130 -60
- data/lib/fusion/cli/parser.rb +3 -3
- data/lib/fusion/cli/repl.rb +30 -25
- data/lib/fusion/cli/serializer.rb +5 -4
- data/lib/fusion/cli.rb +119 -48
- data/lib/fusion/interpreter/builtins.rb +260 -151
- data/lib/fusion/interpreter/env.rb +42 -12
- data/lib/fusion/interpreter/error_val.rb +42 -20
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +239 -82
- data/lib/fusion/lexer.rb +69 -3
- data/lib/fusion/parser.rb +189 -51
- data/lib/fusion/version.rb +1 -1
- 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 -4
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/sanitize.fsn +2 -2
- data/stdlib/truthy.fsn +7 -0
- metadata +18 -4
- data/lib/fusion/interpreter/file_thunk.rb +0 -39
- data/stdlib/mapValues.fsn +0 -5
- data/stdlib/math/square.fsn +0 -4
data/lib/fusion/interpreter.rb
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
# object -> Hash (String keys, insertion-ordered as Ruby preserves)
|
|
19
19
|
# func -> Func (closure over an Env)
|
|
20
20
|
|
|
21
|
+
require "pathname"
|
|
22
|
+
|
|
21
23
|
require_relative "ast"
|
|
22
24
|
require_relative "null"
|
|
23
25
|
require_relative "interpreter/error_val"
|
|
@@ -25,33 +27,41 @@ require_relative "interpreter/func"
|
|
|
25
27
|
require_relative "interpreter/native_func"
|
|
26
28
|
require_relative "interpreter/builtins"
|
|
27
29
|
require_relative "interpreter/env"
|
|
28
|
-
require_relative "interpreter/
|
|
30
|
+
require_relative "interpreter/thunk"
|
|
29
31
|
|
|
30
32
|
module Fusion
|
|
31
33
|
class Interpreter
|
|
32
34
|
include AST
|
|
33
35
|
|
|
34
|
-
|
|
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
|
|
35
41
|
|
|
36
|
-
|
|
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)
|
|
37
47
|
@stdlib_dir = File.expand_path("../../stdlib", __dir__)
|
|
38
48
|
raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir)
|
|
39
49
|
|
|
50
|
+
@env = env
|
|
40
51
|
@env_vars = env_vars || ENV.to_h
|
|
41
|
-
@file_cache = {} # abspath ->
|
|
52
|
+
@file_cache = {} # abspath -> Thunk
|
|
42
53
|
@ast_cache = {} # abspath -> AST
|
|
43
54
|
@builtins = {} # name -> NativeFunc (consulted by @name, not via env)
|
|
44
55
|
Builtins.install(@builtins, self)
|
|
45
|
-
@root_env = Env.new # holds no builtins now; bare identifiers are holes only
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
# Apply the program to one input behind a safety net: a Ruby-level failure
|
|
49
59
|
# (notably a stack overflow) becomes a payloaded error rather than a raw
|
|
50
60
|
# backtrace, so the stdout/stderr contract always holds. In the stream the
|
|
51
61
|
# error is one record's output and the next line continues.
|
|
52
|
-
def self.safe_apply(function, input)
|
|
62
|
+
def self.safe_apply(function, input, environment)
|
|
53
63
|
safe do
|
|
54
|
-
new.apply(function, input)
|
|
64
|
+
new(environment).apply(function, input)
|
|
55
65
|
end
|
|
56
66
|
end
|
|
57
67
|
|
|
@@ -61,7 +71,7 @@ module Fusion
|
|
|
61
71
|
# expression entry is the expression itself.
|
|
62
72
|
def self.safe_evaluate(expression, environment)
|
|
63
73
|
safe do
|
|
64
|
-
new.
|
|
74
|
+
new(environment).evaluate_unit(expression)
|
|
65
75
|
end
|
|
66
76
|
end
|
|
67
77
|
|
|
@@ -71,84 +81,141 @@ module Fusion
|
|
|
71
81
|
# An interpreter bug. Allowed to surface.
|
|
72
82
|
raise
|
|
73
83
|
rescue StandardError => err
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
kind: "type_error", location: "interpreter", operation: "running the program",
|
|
84
|
+
Interpreter::ErrorVal.from_runtime(
|
|
85
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
77
86
|
input: NULL, message: err.message
|
|
78
87
|
)
|
|
79
88
|
rescue SystemExit
|
|
80
89
|
# Let exit/abort through.
|
|
81
90
|
raise
|
|
82
91
|
rescue SystemStackError
|
|
83
|
-
Interpreter::ErrorVal.
|
|
84
|
-
kind: "
|
|
85
|
-
input: NULL, message: "
|
|
92
|
+
Interpreter::ErrorVal.from_runtime(
|
|
93
|
+
kind: "limit_error", origin: "interpreter", operation: "running the program",
|
|
94
|
+
input: NULL, message: "stack level too deep"
|
|
86
95
|
)
|
|
87
96
|
rescue Exception => err # rubocop:disable Lint/RescueException
|
|
88
97
|
# Final net: any other escaped Ruby error becomes a payloaded error too.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
kind: "type_error", location: "interpreter", operation: "running the program",
|
|
98
|
+
Interpreter::ErrorVal.from_runtime(
|
|
99
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
92
100
|
input: NULL, message: err.message
|
|
93
101
|
)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
# ---- File loading -----------------------------------------------------
|
|
105
|
+
# `Thunk` does cycle-detection and result-memoization.
|
|
97
106
|
def load_file(abspath)
|
|
98
|
-
@file_cache[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
|
|
99
117
|
end
|
|
100
118
|
|
|
101
|
-
# The error
|
|
102
|
-
|
|
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)
|
|
103
123
|
if abspath.start_with?(@stdlib_dir + File::SEPARATOR)
|
|
104
|
-
"stdlib
|
|
124
|
+
{ origin: "stdlib" }
|
|
105
125
|
else
|
|
106
|
-
"code
|
|
126
|
+
{ origin: "code", file: display_path(abspath) }
|
|
107
127
|
end
|
|
108
128
|
end
|
|
109
129
|
|
|
110
|
-
# The error
|
|
111
|
-
def
|
|
112
|
-
f = env.
|
|
130
|
+
# The error fields `{origin:, file:}` for code being evaluated under `env`.
|
|
131
|
+
def code_site(env)
|
|
132
|
+
f = env.context(:file)
|
|
113
133
|
if f == :__unbound__
|
|
114
|
-
# Inline (`-e`) programs
|
|
115
|
-
"code <inline>"
|
|
134
|
+
# Inline (`-e`) programs and REPL entries report an "<inline>" file.
|
|
135
|
+
{ origin: "code", file: "<inline>" }
|
|
116
136
|
else
|
|
117
|
-
|
|
137
|
+
file_site(f)
|
|
118
138
|
end
|
|
119
139
|
end
|
|
120
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.
|
|
121
155
|
def evaluate_file(abspath)
|
|
122
|
-
loc = file_location(abspath)
|
|
123
156
|
ast = (@ast_cache[abspath] ||= begin
|
|
124
157
|
src = File.read(abspath)
|
|
125
|
-
Parser.parse_file(src,
|
|
158
|
+
Parser.parse_file(src, site: file_site(abspath))
|
|
126
159
|
end)
|
|
127
160
|
|
|
128
161
|
if ast.is_a?(ErrorVal) # a parse error (already a payloaded value)
|
|
129
162
|
ast
|
|
130
163
|
else
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
env
|
|
134
|
-
env.
|
|
135
|
-
env.define("__file__", abspath)
|
|
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
|
|
136
168
|
eval_expr(ast, env)
|
|
137
169
|
end
|
|
138
170
|
rescue Errno::ENOENT
|
|
139
|
-
|
|
140
|
-
rescue SystemCallError => err # EISDIR, EACCES, ...
|
|
141
|
-
|
|
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
|
|
142
188
|
end
|
|
143
189
|
|
|
144
190
|
# Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
|
|
145
|
-
# `
|
|
146
|
-
|
|
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)
|
|
147
194
|
sibling_file = File.expand_path(name + ".fsn", dir)
|
|
148
195
|
if File.exist?(sibling_file)
|
|
149
|
-
return
|
|
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)
|
|
150
199
|
end
|
|
151
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)
|
|
152
219
|
if name == "ENV"
|
|
153
220
|
return @env_vars.dup
|
|
154
221
|
end
|
|
@@ -158,17 +225,25 @@ module Fusion
|
|
|
158
225
|
# loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
|
|
159
226
|
d = dir
|
|
160
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
|
+
|
|
161
232
|
unless v.is_a?(String)
|
|
162
|
-
next ErrorVal.
|
|
233
|
+
next ErrorVal.from_runtime(kind: "argument_error", **site, operation: "@load", input: v, expected: ["_ ? @String"])
|
|
163
234
|
end
|
|
164
235
|
|
|
165
236
|
target = File.expand_path(v, d)
|
|
166
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
|
+
|
|
167
242
|
unless File.exist?(target)
|
|
168
|
-
next ErrorVal.
|
|
243
|
+
next ErrorVal.from_runtime(kind: "reference_error", **site, operation: "@load", input: v, message: "file not found")
|
|
169
244
|
end
|
|
170
245
|
|
|
171
|
-
load_file(target).force
|
|
246
|
+
load_file(target).force(operation: "@load", input: v, site: site)
|
|
172
247
|
end)
|
|
173
248
|
end
|
|
174
249
|
|
|
@@ -178,15 +253,43 @@ module Fusion
|
|
|
178
253
|
|
|
179
254
|
stdlib_file = File.join(@stdlib_dir, name + ".fsn")
|
|
180
255
|
if File.exist?(stdlib_file)
|
|
181
|
-
|
|
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)
|
|
182
258
|
end
|
|
183
259
|
|
|
184
|
-
ErrorVal.
|
|
260
|
+
ErrorVal.from_runtime(kind: "reference_error", **site, operation: reference, input: NULL, message: "unresolved reference")
|
|
185
261
|
end
|
|
186
262
|
|
|
187
263
|
# Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
|
|
188
|
-
|
|
189
|
-
|
|
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")
|
|
190
293
|
end
|
|
191
294
|
|
|
192
295
|
# ---- Expression evaluation -------------------------------------------
|
|
@@ -194,9 +297,12 @@ module Fusion
|
|
|
194
297
|
case node
|
|
195
298
|
when Expression::Lit then node.value
|
|
196
299
|
when Expression::ErrLit
|
|
300
|
+
# Mark errors from within the stdlib as runtime-produced.
|
|
301
|
+
runtime = code_site(env)[:origin] == "stdlib"
|
|
302
|
+
|
|
197
303
|
if node.payload.nil?
|
|
198
304
|
# Bare `!` means `!null`
|
|
199
|
-
ErrorVal.new(NULL)
|
|
305
|
+
ErrorVal.new(NULL, runtime: runtime)
|
|
200
306
|
else
|
|
201
307
|
payload = eval_expr(node.payload, env)
|
|
202
308
|
|
|
@@ -204,36 +310,39 @@ module Fusion
|
|
|
204
310
|
# No nested errors. Propagate inner error.
|
|
205
311
|
payload
|
|
206
312
|
else
|
|
207
|
-
ErrorVal.new(payload)
|
|
313
|
+
ErrorVal.new(payload, runtime: runtime)
|
|
208
314
|
end
|
|
209
315
|
end
|
|
210
316
|
when Expression::Ident
|
|
211
317
|
value = env.lookup(node.name)
|
|
212
318
|
|
|
213
319
|
if value == :__unbound__
|
|
214
|
-
ErrorVal.
|
|
320
|
+
ErrorVal.from_runtime(kind: "binding_error", **code_site(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
|
|
215
321
|
else
|
|
216
322
|
value
|
|
217
323
|
end
|
|
218
324
|
when Expression::FileRef
|
|
219
|
-
dir = env.
|
|
325
|
+
dir = env.context(:dir)
|
|
220
326
|
dir = Dir.pwd if dir == :__unbound__
|
|
221
327
|
case node.variety
|
|
222
328
|
when :self
|
|
223
|
-
# Bare `@` is the current
|
|
224
|
-
|
|
225
|
-
# refer to the whole inline program (tracked as a gap).
|
|
226
|
-
file = env.lookup("__file__")
|
|
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)
|
|
227
331
|
|
|
228
|
-
if
|
|
229
|
-
|
|
230
|
-
else
|
|
231
|
-
load_file(file).force
|
|
332
|
+
if self_thunk == :__unbound__
|
|
333
|
+
raise Unreachable, "bare @ evaluated outside a top-level unit"
|
|
232
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}")
|
|
233
342
|
when :name
|
|
234
|
-
resolve_name(node.path, dir,
|
|
343
|
+
resolve_name(node.path, dir, code_site(env), "@#{node.path}")
|
|
235
344
|
else # :path
|
|
236
|
-
resolve_path(node.path, dir)
|
|
345
|
+
resolve_path(node.path, dir, code_site(env), "@#{node.path}")
|
|
237
346
|
end
|
|
238
347
|
when Expression::ArrLit then eval_array(node, env)
|
|
239
348
|
when Expression::ObjLit then eval_object(node, env)
|
|
@@ -241,6 +350,7 @@ module Fusion
|
|
|
241
350
|
when Expression::Pipe then eval_pipe(node, env)
|
|
242
351
|
when Expression::Member then eval_member(node, env)
|
|
243
352
|
when Expression::Index then eval_index(node, env)
|
|
353
|
+
when Expression::IndexSet then eval_index_set(node, env)
|
|
244
354
|
else
|
|
245
355
|
raise Unreachable, "Unknown AST node #{node.class}"
|
|
246
356
|
end
|
|
@@ -267,7 +377,7 @@ module Fusion
|
|
|
267
377
|
if value.is_a?(Array)
|
|
268
378
|
out.concat(value)
|
|
269
379
|
else
|
|
270
|
-
return ErrorVal.
|
|
380
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "[...] array spread", input: value, expected: ["_ ? @Array"])
|
|
271
381
|
end
|
|
272
382
|
else
|
|
273
383
|
raise Unreachable, "Unknown array item #{item.class}"
|
|
@@ -295,7 +405,7 @@ module Fusion
|
|
|
295
405
|
if value.is_a?(Hash)
|
|
296
406
|
out.merge!(value)
|
|
297
407
|
else
|
|
298
|
-
return ErrorVal.
|
|
408
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "{...} object spread", input: value, expected: ["_ ? @Object"])
|
|
299
409
|
end
|
|
300
410
|
else
|
|
301
411
|
raise Unreachable, "Unknown object pair #{pair.class}"
|
|
@@ -308,7 +418,7 @@ module Fusion
|
|
|
308
418
|
def eval_pipe(node, env)
|
|
309
419
|
value = eval_expr(node.left, env)
|
|
310
420
|
function = eval_expr(node.right, env)
|
|
311
|
-
apply(function, value,
|
|
421
|
+
apply(function, value, call_site(env))
|
|
312
422
|
end
|
|
313
423
|
|
|
314
424
|
def eval_member(node, env)
|
|
@@ -319,13 +429,13 @@ module Fusion
|
|
|
319
429
|
return obj
|
|
320
430
|
end
|
|
321
431
|
|
|
322
|
-
|
|
432
|
+
site = code_site(env)
|
|
323
433
|
unless obj.is_a?(Hash)
|
|
324
|
-
return ErrorVal.
|
|
434
|
+
return ErrorVal.from_runtime(kind: "argument_error", **site, operation: ".#{node.key}", input: obj, expected: ["_ ? @Object"])
|
|
325
435
|
end
|
|
326
436
|
|
|
327
437
|
unless obj.key?(node.key)
|
|
328
|
-
return ErrorVal.
|
|
438
|
+
return ErrorVal.from_runtime(kind: "access_error", **site, operation: ".#{node.key}", input: obj, message: "missing key")
|
|
329
439
|
end
|
|
330
440
|
|
|
331
441
|
obj[node.key]
|
|
@@ -346,30 +456,70 @@ module Fusion
|
|
|
346
456
|
return idx
|
|
347
457
|
end
|
|
348
458
|
|
|
349
|
-
|
|
459
|
+
site = code_site(env)
|
|
350
460
|
if obj.is_a?(Array) && idx.is_a?(Integer)
|
|
351
461
|
i = idx >= 0 ? idx : obj.length + idx
|
|
352
462
|
if i >= 0 && i < obj.length
|
|
353
463
|
obj[i]
|
|
354
464
|
else
|
|
355
|
-
ErrorVal.
|
|
465
|
+
ErrorVal.from_runtime(kind: "access_error", **site, operation: "[]", input: [obj, idx], message: "index out of range")
|
|
356
466
|
end
|
|
357
467
|
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
358
468
|
if obj.key?(idx)
|
|
359
469
|
obj[idx]
|
|
360
470
|
else
|
|
361
|
-
ErrorVal.
|
|
471
|
+
ErrorVal.from_runtime(kind: "access_error", **site, operation: "[]", input: [obj, idx], message: "missing key")
|
|
362
472
|
end
|
|
363
473
|
else
|
|
364
|
-
ErrorVal.
|
|
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, _]"])
|
|
365
504
|
end
|
|
366
505
|
end
|
|
367
506
|
|
|
368
507
|
# ---- Application & matching ------------------------------------------
|
|
369
|
-
# `
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
|
|
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)
|
|
373
523
|
if f.is_a?(ErrorVal)
|
|
374
524
|
# Propagate errors
|
|
375
525
|
return f
|
|
@@ -386,20 +536,27 @@ module Fusion
|
|
|
386
536
|
begin
|
|
387
537
|
f.fn.call(v)
|
|
388
538
|
rescue StandardError => err
|
|
389
|
-
|
|
390
|
-
|
|
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)
|
|
391
542
|
end
|
|
392
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
|
+
|
|
393
549
|
f.clauses.each do |clause|
|
|
394
550
|
# Bindings are inserted directly into a fresh child env as the pattern
|
|
395
551
|
# matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
|
|
396
552
|
# convert to a binding_error here. A failed/abandoned clause just drops
|
|
397
553
|
# its env, so partial bindings never leak.
|
|
398
554
|
clause_env = f.env.child
|
|
555
|
+
clause_env.set_context(:call_site, body_call_site) if body_call_site
|
|
399
556
|
m = begin
|
|
400
557
|
match(clause.pattern, v, clause_env)
|
|
401
558
|
rescue Env::DuplicateBinding => e
|
|
402
|
-
return ErrorVal.
|
|
559
|
+
return ErrorVal.from_runtime(kind: "binding_error", **code_site(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
|
|
403
560
|
end
|
|
404
561
|
|
|
405
562
|
if m.is_a?(ErrorVal)
|
|
@@ -419,7 +576,7 @@ module Fusion
|
|
|
419
576
|
# lenient default is `null`.
|
|
420
577
|
v.is_a?(ErrorVal) ? v : NULL
|
|
421
578
|
else
|
|
422
|
-
ErrorVal.
|
|
579
|
+
ErrorVal.from_runtime(kind: "argument_error", origin: "code", file: call_site, operation: "|", input: [v, f], expected: ["[_, _ ? @Function]"])
|
|
423
580
|
end
|
|
424
581
|
end
|
|
425
582
|
|
|
@@ -431,9 +588,9 @@ module Fusion
|
|
|
431
588
|
def apply_predicate(pred_expr, value, env)
|
|
432
589
|
if pred_expr.is_a?(Expression::Pipe)
|
|
433
590
|
upstream = apply_predicate(pred_expr.left, value, env)
|
|
434
|
-
apply(eval_expr(pred_expr.right, env), upstream,
|
|
591
|
+
apply(eval_expr(pred_expr.right, env), upstream, call_site(env))
|
|
435
592
|
else
|
|
436
|
-
apply(eval_expr(pred_expr, env), value,
|
|
593
|
+
apply(eval_expr(pred_expr, env), value, call_site(env))
|
|
437
594
|
end
|
|
438
595
|
end
|
|
439
596
|
|