fusion-lang 0.0.1.alpha1 → 0.0.1

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