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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -8
  3. data/docs/lang/design.md +240 -51
  4. data/docs/lang/implementation.md +238 -0
  5. data/docs/lang/roadmap.md +20 -36
  6. data/docs/user/explanation.md +5 -10
  7. data/docs/user/how-to-guides.md +60 -15
  8. data/docs/user/reference.md +356 -142
  9. data/docs/user/tutorial.md +21 -19
  10. data/examples/double.fsn +1 -1
  11. data/examples/factorial.fsn +2 -2
  12. data/examples/fizzbuzz.fsn +1 -4
  13. data/examples/json_test.fsn +4 -0
  14. data/examples/palindrome.fsn +1 -1
  15. data/exe/fusion +10 -10
  16. data/lib/fusion/ast.rb +2 -1
  17. data/lib/fusion/cli/decoder.rb +10 -5
  18. data/lib/fusion/cli/options.rb +130 -60
  19. data/lib/fusion/cli/parser.rb +3 -3
  20. data/lib/fusion/cli/repl.rb +30 -25
  21. data/lib/fusion/cli/serializer.rb +5 -4
  22. data/lib/fusion/cli.rb +119 -48
  23. data/lib/fusion/interpreter/builtins.rb +260 -151
  24. data/lib/fusion/interpreter/env.rb +42 -12
  25. data/lib/fusion/interpreter/error_val.rb +42 -20
  26. data/lib/fusion/interpreter/thunk.rb +53 -0
  27. data/lib/fusion/interpreter.rb +239 -82
  28. data/lib/fusion/lexer.rb +69 -3
  29. data/lib/fusion/parser.rb +189 -51
  30. data/lib/fusion/version.rb +1 -1
  31. data/stdlib/all.fsn +13 -0
  32. data/stdlib/any.fsn +12 -0
  33. data/stdlib/chars.fsn +5 -0
  34. data/stdlib/compact.fsn +6 -0
  35. data/stdlib/concat.fsn +5 -0
  36. data/stdlib/falsey.fsn +6 -0
  37. data/stdlib/filter.fsn +12 -0
  38. data/stdlib/flatten.fsn +7 -0
  39. data/stdlib/gt.fsn +9 -0
  40. data/stdlib/gte.fsn +9 -0
  41. data/stdlib/lt.fsn +9 -0
  42. data/stdlib/lte.fsn +9 -0
  43. data/stdlib/map.fsn +6 -4
  44. data/stdlib/range.fsn +2 -2
  45. data/stdlib/reduce.fsn +8 -0
  46. data/stdlib/sanitize.fsn +2 -2
  47. data/stdlib/truthy.fsn +7 -0
  48. metadata +18 -4
  49. data/lib/fusion/interpreter/file_thunk.rb +0 -39
  50. data/stdlib/mapValues.fsn +0 -5
  51. 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
 
@@ -71,84 +81,141 @@ module Fusion
71
81
  # An interpreter bug. Allowed to surface.
72
82
  raise
73
83
  rescue StandardError => err
74
- # TODO: change type
75
- Interpreter::ErrorVal.internal(
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.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
96
  rescue Exception => err # 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",
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] ||= 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 => 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
- # `location` is the "code X" of the referencing file (for the unresolved case).
146
- def resolve_name(name, dir, location)
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 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
 
@@ -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
- 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
@@ -267,7 +377,7 @@ module Fusion
267
377
  if value.is_a?(Array)
268
378
  out.concat(value)
269
379
  else
270
- return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "[...] array spread", input: value, message: "expected an array")
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.internal(kind: "type_error", location: code_location(env), operation: "{...} object spread", input: value, message: "expected an object")
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, 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")
362
472
  end
363
473
  else
364
- ErrorVal.internal(kind: "type_error", location: loc, operation: "[index]", input: [obj, idx], message: "bad index type")
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
- # `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
@@ -386,20 +536,27 @@ module Fusion
386
536
  begin
387
537
  f.fn.call(v)
388
538
  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)
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.internal(kind: "binding_error", location: code_location(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
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.internal(kind: "type_error", location: location, operation: "|", input: [v, f], message: "applied a non-function")
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, code_location(env))
591
+ apply(eval_expr(pred_expr.right, env), upstream, call_site(env))
435
592
  else
436
- apply(eval_expr(pred_expr, env), value, code_location(env))
593
+ apply(eval_expr(pred_expr, env), value, call_site(env))
437
594
  end
438
595
  end
439
596