fusion-lang 0.0.1.alpha2 → 0.0.2
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/.mutant.yml +24 -0
- data/.simplecov +11 -0
- data/CHANGELOG.md +42 -0
- data/README.md +11 -9
- data/Rakefile +8 -0
- data/docs/lang/design.md +289 -51
- data/docs/lang/implementation.md +279 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +145 -15
- data/docs/user/reference.md +365 -140
- data/docs/user/tutorial.md +22 -19
- data/examples/double.fsn +4 -1
- data/examples/factorial.fsn +6 -3
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/gcd.fsn +9 -0
- data/examples/json_test.fsn +4 -0
- data/examples/matrix/OP.fsn +2 -0
- data/examples/matrix/average.fsn +2 -0
- data/examples/matrix/solve.fsn +2 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +12 -12
- data/lib/fusion/ast.rb +76 -27
- data/lib/fusion/atom.rb +1 -1
- data/lib/fusion/cli/decoder.rb +13 -8
- data/lib/fusion/cli/encoder.rb +2 -2
- data/lib/fusion/cli/options.rb +134 -61
- data/lib/fusion/cli/parser.rb +4 -4
- data/lib/fusion/cli/repl.rb +32 -27
- data/lib/fusion/cli/serializer.rb +11 -11
- data/lib/fusion/cli.rb +120 -49
- data/lib/fusion/interpreter/builtins.rb +298 -160
- 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 +263 -98
- data/lib/fusion/lexer.rb +125 -37
- data/lib/fusion/parser.rb +245 -70
- data/lib/fusion/version.rb +3 -1
- data/lib/fusion.rb +0 -1
- data/stdlib/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +5 -0
- data/stdlib/concat.fsn +6 -0
- data/stdlib/entries.fsn +6 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/map.fsn +7 -4
- data/stdlib/matrix/Matrix.fsn +8 -0
- data/stdlib/matrix/OP.fsn +18 -0
- data/stdlib/matrix/add.fsn +7 -0
- data/stdlib/matrix/column.fsn +6 -0
- data/stdlib/matrix/determinant.fsn +16 -0
- data/stdlib/matrix/dimensions.fsn +5 -0
- data/stdlib/matrix/identity.fsn +6 -0
- data/stdlib/matrix/invert.fsn +26 -0
- data/stdlib/matrix/minor.fsn +15 -0
- data/stdlib/matrix/multiply.fsn +10 -0
- data/stdlib/matrix/negate.fsn +5 -0
- data/stdlib/matrix/product.fsn +11 -0
- data/stdlib/matrix/rotate.fsn +10 -0
- data/stdlib/matrix/row.fsn +6 -0
- data/stdlib/matrix/scale.fsn +6 -0
- data/stdlib/matrix/subtract.fsn +7 -0
- data/stdlib/matrix/sum.fsn +14 -0
- data/stdlib/matrix/transpose.fsn +5 -0
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/safe.fsn +7 -0
- data/stdlib/sanitize.fsn +2 -3
- data/stdlib/toObject.fsn +7 -0
- data/stdlib/truthy.fsn +7 -0
- data/stdlib/vector/Vector.fsn +7 -0
- data/stdlib/vector/add.fsn +6 -0
- data/stdlib/vector/cross.fsn +6 -0
- data/stdlib/vector/dot.fsn +6 -0
- data/stdlib/vector/norm.fsn +6 -0
- data/stdlib/vector/scale.fsn +5 -0
- data/stdlib/vector/subtract.fsn +6 -0
- data/stdlib/zip.fsn +6 -0
- metadata +50 -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
|
|
|
@@ -70,85 +80,142 @@ module Fusion
|
|
|
70
80
|
rescue Unreachable
|
|
71
81
|
# An interpreter bug. Allowed to surface.
|
|
72
82
|
raise
|
|
73
|
-
rescue StandardError =>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
input: NULL, message: err.message
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Interpreter::ErrorVal.from_runtime(
|
|
85
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
86
|
+
input: NULL, message: e.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
|
-
rescue Exception =>
|
|
96
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
88
97
|
# Final net: any other escaped Ruby error becomes a payloaded error too.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
input: NULL, message: err.message
|
|
98
|
+
Interpreter::ErrorVal.from_runtime(
|
|
99
|
+
kind: "internal_error", origin: "interpreter", operation: "running the program",
|
|
100
|
+
input: NULL, message: e.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 =>
|
|
141
|
-
|
|
171
|
+
raise Thunk::ReadFailure, "file not found"
|
|
172
|
+
rescue SystemCallError => e # EISDIR, EACCES, ... (file-system access failures)
|
|
173
|
+
# Drop Ruby's "@ io_fread - <path>" tail.
|
|
174
|
+
raise Thunk::ReadFailure, e.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
|
-
|
|
147
|
-
|
|
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)
|
|
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
|
|
|
@@ -176,17 +251,45 @@ module Fusion
|
|
|
176
251
|
return @builtins[name]
|
|
177
252
|
end
|
|
178
253
|
|
|
179
|
-
stdlib_file = File.join(@stdlib_dir, name
|
|
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
|
|
@@ -264,11 +374,11 @@ module Fusion
|
|
|
264
374
|
when ArrayItem
|
|
265
375
|
out.append(value)
|
|
266
376
|
when ArraySpread
|
|
267
|
-
if value.is_a?(Array)
|
|
268
|
-
|
|
269
|
-
else
|
|
270
|
-
return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "[...] array spread", input: value, message: "expected an array")
|
|
377
|
+
if !value.is_a?(Array)
|
|
378
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "[...] array spread", input: value, expected: ["_ ? @Array"])
|
|
271
379
|
end
|
|
380
|
+
|
|
381
|
+
out.concat(value)
|
|
272
382
|
else
|
|
273
383
|
raise Unreachable, "Unknown array item #{item.class}"
|
|
274
384
|
end
|
|
@@ -292,11 +402,11 @@ module Fusion
|
|
|
292
402
|
when KeyValuePair
|
|
293
403
|
out[pair.key] = value
|
|
294
404
|
when ObjectSpread
|
|
295
|
-
if value.is_a?(Hash)
|
|
296
|
-
|
|
297
|
-
else
|
|
298
|
-
return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "{...} object spread", input: value, message: "expected an object")
|
|
405
|
+
if !value.is_a?(Hash)
|
|
406
|
+
return ErrorVal.from_runtime(kind: "argument_error", **code_site(env), operation: "{...} object spread", input: value, expected: ["_ ? @Object"])
|
|
299
407
|
end
|
|
408
|
+
|
|
409
|
+
out.merge!(value)
|
|
300
410
|
else
|
|
301
411
|
raise Unreachable, "Unknown object pair #{pair.class}"
|
|
302
412
|
end
|
|
@@ -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")
|
|
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")
|
|
362
499
|
end
|
|
500
|
+
elsif obj.is_a?(Hash) && idx.is_a?(String)
|
|
501
|
+
obj.merge(idx => value)
|
|
363
502
|
else
|
|
364
|
-
ErrorVal.
|
|
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
|
|
@@ -385,21 +535,33 @@ module Fusion
|
|
|
385
535
|
# becomes a payloaded error rather than a raw backtrace on stderr.
|
|
386
536
|
begin
|
|
387
537
|
f.fn.call(v)
|
|
388
|
-
rescue StandardError =>
|
|
389
|
-
|
|
390
|
-
|
|
538
|
+
rescue StandardError => e
|
|
539
|
+
# TODO: move math errors into the builtins. This should become a safety net for unpredicted errors.
|
|
540
|
+
kind = case e
|
|
541
|
+
when FloatDomainError, ZeroDivisionError, Math::DomainError
|
|
542
|
+
"math_error"
|
|
543
|
+
else
|
|
544
|
+
"internal_error"
|
|
545
|
+
end
|
|
546
|
+
ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{f.name}", input: v, message: e.message)
|
|
391
547
|
end
|
|
392
548
|
elsif f.is_a?(Func)
|
|
549
|
+
# Stdlib code has no user file of its own: errors inside it (and in the
|
|
550
|
+
# built-ins it calls) report the user `call_site` that reached it. User and
|
|
551
|
+
# inline functions are their own call site (derived lexically from their env).
|
|
552
|
+
body_call_site = code_site(f.env)[:origin] == "stdlib" ? call_site : nil
|
|
553
|
+
|
|
393
554
|
f.clauses.each do |clause|
|
|
394
555
|
# Bindings are inserted directly into a fresh child env as the pattern
|
|
395
556
|
# matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
|
|
396
557
|
# convert to a binding_error here. A failed/abandoned clause just drops
|
|
397
558
|
# its env, so partial bindings never leak.
|
|
398
559
|
clause_env = f.env.child
|
|
560
|
+
clause_env.set_context(:call_site, body_call_site) if body_call_site
|
|
399
561
|
m = begin
|
|
400
562
|
match(clause.pattern, v, clause_env)
|
|
401
563
|
rescue Env::DuplicateBinding => e
|
|
402
|
-
return ErrorVal.
|
|
564
|
+
return ErrorVal.from_runtime(kind: "binding_error", **code_site(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
|
|
403
565
|
end
|
|
404
566
|
|
|
405
567
|
if m.is_a?(ErrorVal)
|
|
@@ -419,7 +581,7 @@ module Fusion
|
|
|
419
581
|
# lenient default is `null`.
|
|
420
582
|
v.is_a?(ErrorVal) ? v : NULL
|
|
421
583
|
else
|
|
422
|
-
ErrorVal.
|
|
584
|
+
ErrorVal.from_runtime(kind: "argument_error", origin: "code", file: call_site, operation: "|", input: [v, f], expected: ["[_, _ ? @Function]"])
|
|
423
585
|
end
|
|
424
586
|
end
|
|
425
587
|
|
|
@@ -431,9 +593,9 @@ module Fusion
|
|
|
431
593
|
def apply_predicate(pred_expr, value, env)
|
|
432
594
|
if pred_expr.is_a?(Expression::Pipe)
|
|
433
595
|
upstream = apply_predicate(pred_expr.left, value, env)
|
|
434
|
-
apply(eval_expr(pred_expr.right, env), upstream,
|
|
596
|
+
apply(eval_expr(pred_expr.right, env), upstream, call_site(env))
|
|
435
597
|
else
|
|
436
|
-
apply(eval_expr(pred_expr, env), value,
|
|
598
|
+
apply(eval_expr(pred_expr, env), value, call_site(env))
|
|
437
599
|
end
|
|
438
600
|
end
|
|
439
601
|
|
|
@@ -489,7 +651,7 @@ module Fusion
|
|
|
489
651
|
if predicate_result.is_a?(ErrorVal)
|
|
490
652
|
# An unresolved @-reference, or an error raised while applying the
|
|
491
653
|
# predicate, becomes the clause's result.
|
|
492
|
-
|
|
654
|
+
predicate_result
|
|
493
655
|
else
|
|
494
656
|
# Ruby-style truthiness: the clause matches unless the predicate
|
|
495
657
|
# yields `false` or `null`.
|
|
@@ -515,11 +677,11 @@ module Fusion
|
|
|
515
677
|
return r if r.is_a?(ErrorVal)
|
|
516
678
|
return false unless r
|
|
517
679
|
end
|
|
518
|
-
true
|
|
519
680
|
else
|
|
520
681
|
before = items[0...rest_index]
|
|
521
682
|
after = items[(rest_index + 1)..]
|
|
522
683
|
return false if value.length < before.length + after.length
|
|
684
|
+
|
|
523
685
|
before.each_with_index do |item, i|
|
|
524
686
|
r = match(item.pattern, value[i], env)
|
|
525
687
|
return r if r.is_a?(ErrorVal)
|
|
@@ -536,8 +698,8 @@ module Fusion
|
|
|
536
698
|
mid = value[before.length...(value.length - after.length)]
|
|
537
699
|
env.bind(rest_name, mid)
|
|
538
700
|
end
|
|
539
|
-
true
|
|
540
701
|
end
|
|
702
|
+
true
|
|
541
703
|
end
|
|
542
704
|
|
|
543
705
|
def match_object(pattern, value, env)
|
|
@@ -551,9 +713,11 @@ module Fusion
|
|
|
551
713
|
rest_name = pair.name # may be nil (ignore) or a string
|
|
552
714
|
when PatternPair
|
|
553
715
|
return false unless value.key?(pair.key)
|
|
716
|
+
|
|
554
717
|
r = match(pair.pattern, value[pair.key], env)
|
|
555
718
|
return r if r.is_a?(ErrorVal)
|
|
556
719
|
return false unless r
|
|
720
|
+
|
|
557
721
|
matched_keys << pair.key
|
|
558
722
|
else
|
|
559
723
|
raise Unreachable, "Unknown object pattern pair #{pair.class}"
|
|
@@ -582,6 +746,7 @@ module Fusion
|
|
|
582
746
|
def deep_equal?(a, b)
|
|
583
747
|
return true if a.equal?(b)
|
|
584
748
|
return false if a.class != b.class
|
|
749
|
+
|
|
585
750
|
case a
|
|
586
751
|
when Array
|
|
587
752
|
a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
|