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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +24 -0
  3. data/.simplecov +11 -0
  4. data/CHANGELOG.md +42 -0
  5. data/README.md +11 -9
  6. data/Rakefile +8 -0
  7. data/docs/lang/design.md +289 -51
  8. data/docs/lang/implementation.md +279 -0
  9. data/docs/lang/roadmap.md +20 -36
  10. data/docs/user/explanation.md +5 -10
  11. data/docs/user/how-to-guides.md +145 -15
  12. data/docs/user/reference.md +365 -140
  13. data/docs/user/tutorial.md +22 -19
  14. data/examples/double.fsn +4 -1
  15. data/examples/factorial.fsn +6 -3
  16. data/examples/fizzbuzz.fsn +1 -4
  17. data/examples/gcd.fsn +9 -0
  18. data/examples/json_test.fsn +4 -0
  19. data/examples/matrix/OP.fsn +2 -0
  20. data/examples/matrix/average.fsn +2 -0
  21. data/examples/matrix/solve.fsn +2 -0
  22. data/examples/palindrome.fsn +1 -1
  23. data/exe/fusion +12 -12
  24. data/lib/fusion/ast.rb +76 -27
  25. data/lib/fusion/atom.rb +1 -1
  26. data/lib/fusion/cli/decoder.rb +13 -8
  27. data/lib/fusion/cli/encoder.rb +2 -2
  28. data/lib/fusion/cli/options.rb +134 -61
  29. data/lib/fusion/cli/parser.rb +4 -4
  30. data/lib/fusion/cli/repl.rb +32 -27
  31. data/lib/fusion/cli/serializer.rb +11 -11
  32. data/lib/fusion/cli.rb +120 -49
  33. data/lib/fusion/interpreter/builtins.rb +298 -160
  34. data/lib/fusion/interpreter/env.rb +42 -12
  35. data/lib/fusion/interpreter/error_val.rb +42 -20
  36. data/lib/fusion/interpreter/thunk.rb +53 -0
  37. data/lib/fusion/interpreter.rb +263 -98
  38. data/lib/fusion/lexer.rb +125 -37
  39. data/lib/fusion/parser.rb +245 -70
  40. data/lib/fusion/version.rb +3 -1
  41. data/lib/fusion.rb +0 -1
  42. data/stdlib/all.fsn +13 -0
  43. data/stdlib/any.fsn +12 -0
  44. data/stdlib/chars.fsn +5 -0
  45. data/stdlib/compact.fsn +5 -0
  46. data/stdlib/concat.fsn +6 -0
  47. data/stdlib/entries.fsn +6 -0
  48. data/stdlib/falsey.fsn +6 -0
  49. data/stdlib/filter.fsn +12 -0
  50. data/stdlib/flatten.fsn +7 -0
  51. data/stdlib/map.fsn +7 -4
  52. data/stdlib/matrix/Matrix.fsn +8 -0
  53. data/stdlib/matrix/OP.fsn +18 -0
  54. data/stdlib/matrix/add.fsn +7 -0
  55. data/stdlib/matrix/column.fsn +6 -0
  56. data/stdlib/matrix/determinant.fsn +16 -0
  57. data/stdlib/matrix/dimensions.fsn +5 -0
  58. data/stdlib/matrix/identity.fsn +6 -0
  59. data/stdlib/matrix/invert.fsn +26 -0
  60. data/stdlib/matrix/minor.fsn +15 -0
  61. data/stdlib/matrix/multiply.fsn +10 -0
  62. data/stdlib/matrix/negate.fsn +5 -0
  63. data/stdlib/matrix/product.fsn +11 -0
  64. data/stdlib/matrix/rotate.fsn +10 -0
  65. data/stdlib/matrix/row.fsn +6 -0
  66. data/stdlib/matrix/scale.fsn +6 -0
  67. data/stdlib/matrix/subtract.fsn +7 -0
  68. data/stdlib/matrix/sum.fsn +14 -0
  69. data/stdlib/matrix/transpose.fsn +5 -0
  70. data/stdlib/range.fsn +2 -2
  71. data/stdlib/reduce.fsn +8 -0
  72. data/stdlib/safe.fsn +7 -0
  73. data/stdlib/sanitize.fsn +2 -3
  74. data/stdlib/toObject.fsn +7 -0
  75. data/stdlib/truthy.fsn +7 -0
  76. data/stdlib/vector/Vector.fsn +7 -0
  77. data/stdlib/vector/add.fsn +6 -0
  78. data/stdlib/vector/cross.fsn +6 -0
  79. data/stdlib/vector/dot.fsn +6 -0
  80. data/stdlib/vector/norm.fsn +6 -0
  81. data/stdlib/vector/scale.fsn +5 -0
  82. data/stdlib/vector/subtract.fsn +6 -0
  83. data/stdlib/zip.fsn +6 -0
  84. metadata +50 -4
  85. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  86. data/stdlib/mapValues.fsn +0 -5
  87. data/stdlib/math/square.fsn +0 -4
@@ -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/file_thunk"
30
+ require_relative "interpreter/thunk"
29
31
 
30
32
  module Fusion
31
33
  class Interpreter
32
34
  include AST
33
35
 
34
- attr_reader :root_env
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
- def initialize(env_vars: nil)
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 -> FileThunk
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.eval_expr(expression, environment)
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 => err
74
- # TODO: change type
75
- Interpreter::ErrorVal.internal(
76
- kind: "type_error", location: "interpreter", operation: "running the program",
77
- input: NULL, message: err.message
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.internal(
84
- kind: "stack_error", location: "interpreter", operation: "running the program",
85
- input: NULL, message: "recursion too deep"
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 => err # rubocop:disable Lint/RescueException
96
+ rescue Exception => e # rubocop:disable Lint/RescueException
88
97
  # Final net: any other escaped Ruby error becomes a payloaded error too.
89
- # TODO: change type
90
- Interpreter::ErrorVal.internal(
91
- kind: "type_error", location: "interpreter", operation: "running the program",
92
- input: NULL, message: err.message
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] ||= FileThunk.new(self, 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 field `location` for code at `abspath`.
102
- def file_location(abspath)
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 #{File.basename(abspath)}"
124
+ { origin: "stdlib" }
105
125
  else
106
- "code #{File.basename(abspath)}"
126
+ { origin: "code", file: display_path(abspath) }
107
127
  end
108
128
  end
109
129
 
110
- # The error field `location` for code being evaluated under `env`.
111
- def code_location(env)
112
- f = env.lookup("__file__")
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 have no file, so they report as "code <inline>".
115
- "code <inline>"
134
+ # Inline (`-e`) programs and REPL entries report an "<inline>" file.
135
+ { origin: "code", file: "<inline>" }
116
136
  else
117
- file_location(f)
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, location: loc)
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
- # A file's value is evaluated in a fresh env whose parent is root (builtins),
132
- # plus knowledge of its own directory for resolving @refs.
133
- env = @root_env.child
134
- env.define("__dir__", File.dirname(abspath))
135
- env.define("__file__", abspath)
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
- ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: "file not found")
140
- rescue SystemCallError => err # EISDIR, EACCES, ... file-system access failures
141
- ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: err.message)
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
- # `location` is the "code X" of the referencing file (for the unresolved case).
146
- def resolve_name(name, dir, location)
147
- sibling_file = File.expand_path(name + ".fsn", dir)
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 load_file(sibling_file).force
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.internal(kind: "type_error", location: "builtin load", operation: "@load", input: v, message: "expected a string")
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.internal(kind: "reference_error", location: "builtin load", operation: "@load", input: v, message: "file not found")
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 + ".fsn")
254
+ stdlib_file = File.join(@stdlib_dir, "#{name}.fsn")
180
255
  if File.exist?(stdlib_file)
181
- return load_file(stdlib_file).force
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.internal(kind: "reference_error", location: location, operation: "resolving @#{name}", input: name, message: "unresolved reference")
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
- def resolve_path(relpath, dir)
189
- load_file(File.expand_path(relpath + ".fsn", dir)).force
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.internal(kind: "binding_error", location: code_location(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
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.lookup("__dir__")
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 file. NOTE: inline (`-e`) programs have no
224
- # current file, so `@` is unresolvable there today — but it *should*
225
- # refer to the whole inline program (tracked as a gap).
226
- file = env.lookup("__file__")
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 file == :__unbound__
229
- ErrorVal.internal(kind: "reference_error", location: code_location(env), operation: "resolving @", input: NULL, message: "no current file for self-reference")
230
- else
231
- load_file(file).force
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, code_location(env))
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
- out.concat(value)
269
- else
270
- return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "[...] array spread", input: value, message: "expected an array")
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
- out.merge!(value)
297
- else
298
- return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "{...} object spread", input: value, message: "expected an object")
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, code_location(env))
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
- loc = code_location(env)
432
+ site = code_site(env)
323
433
  unless obj.is_a?(Hash)
324
- return ErrorVal.internal(kind: "type_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "expected an object")
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.internal(kind: "access_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "missing key")
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
- loc = code_location(env)
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.internal(kind: "access_error", location: loc, operation: "[#{idx}]", input: [obj, idx], message: "index out of range")
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.internal(kind: "access_error", location: loc, operation: "[#{idx.inspect}]", input: [obj, idx], message: "missing key")
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.internal(kind: "type_error", location: loc, operation: "[index]", input: [obj, idx], message: "bad index type")
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
- # `location` is the "code X" where the `|` lives, used if `f` is not a
370
- # function. It defaults to "interpreter" for apply calls with no code context
371
- # (e.g. the CLI applying the whole program).
372
- def apply(f, v, location = "interpreter")
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 => err
389
- kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError)) ? "math_error" : "type_error"
390
- ErrorVal.internal(kind: kind, location: "builtin #{f.name}", operation: f.name, input: v, message: err.message)
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.internal(kind: "binding_error", location: code_location(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
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.internal(kind: "type_error", location: location, operation: "|", input: [v, f], message: "applied a non-function")
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, code_location(env))
596
+ apply(eval_expr(pred_expr.right, env), upstream, call_site(env))
435
597
  else
436
- apply(eval_expr(pred_expr, env), value, code_location(env))
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
- return predicate_result
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]) }