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,465 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Interpreter internals ===
4
+
5
+ module Fusion
6
+ class Interpreter
7
+ module Builtins
8
+ extend self
9
+
10
+ def install(table, interp)
11
+ @interp = interp
12
+ define = ->(name, fn) { table[name] = NativeFunc.new(name, fn) }
13
+
14
+ # Irreducible primitives kept as built-ins. The sugar-target operators
15
+ # (arithmetic/comparison/boolean) live in `@OP` below; their friendly
16
+ # derived forms (`@lt`, `@gt`, `@truthy`, …) are stdlib files that build on
17
+ # `@OP.*`, so they follow a per-directory `@OP` override. Numeric functions
18
+ # (`round`, `divide`, `sin`, …) and constants (`pi`, `e`) live in `@math`.
19
+ define.call("size", method(:size))
20
+ define.call("join", method(:join))
21
+ define.call("split", method(:split))
22
+ define.call("toString", method(:to_string))
23
+ define.call("parseNumber", method(:parse_number))
24
+ define.call("keys", method(:keys))
25
+ define.call("values", method(:values))
26
+ define.call("toObject", method(:to_object))
27
+
28
+ # type predicates: return false on any non-matching value, never an error
29
+ define.call("Integer", method(:integer?))
30
+ define.call("Float", method(:float?))
31
+ define.call("Number", method(:numeric?))
32
+ define.call("String", method(:string?))
33
+ define.call("Boolean", method(:boolean?))
34
+ define.call("Array", method(:array?))
35
+ define.call("Object", method(:object?))
36
+ define.call("Null", method(:null?))
37
+ define.call("Function", method(:function?))
38
+ define.call("NonFinite", method(:non_finite?))
39
+
40
+ # `OP` bundles the sugar-target operators as a shadowable builtin object,
41
+ # reached as `@OP.sum`, `@OP.and`, … A directory can swap the operators by
42
+ # placing an `OP.fsn` sibling that overrides members (reaching the
43
+ # originals with `@@`); infix sugar and the derived stdlib helpers resolve
44
+ # `@OP` per directory, so they follow the override.
45
+ table["OP"] = {
46
+ "sum" => NativeFunc.new("OP.sum", method(:op_sum)),
47
+ "product" => NativeFunc.new("OP.product", method(:op_product)),
48
+ "negate" => NativeFunc.new("OP.negate", method(:op_negate)),
49
+ "invert" => NativeFunc.new("OP.invert", method(:op_invert)),
50
+ "quotient" => NativeFunc.new("OP.quotient", method(:op_quotient)),
51
+ "modulo" => NativeFunc.new("OP.modulo", method(:op_modulo)),
52
+ "equal" => NativeFunc.new("OP.equal", method(:op_equal)),
53
+ "compare" => NativeFunc.new("OP.compare", method(:op_compare)),
54
+ "and" => NativeFunc.new("OP.and", method(:op_and)),
55
+ "or" => NativeFunc.new("OP.or", method(:op_or)),
56
+ "not" => NativeFunc.new("OP.not", method(:op_not)),
57
+ }
58
+
59
+ # `math` bundles numeric functions and constants, reached as `@math.round`,
60
+ # `@math.pi`, … Like `@OP`, it is a shadowable builtin object. `pi`/`e` are
61
+ # plain values; the rest are one-argument functions.
62
+ table["math"] = {
63
+ "round" => NativeFunc.new("math.round", method(:math_round)),
64
+ "floor" => NativeFunc.new("math.floor", method(:math_floor)),
65
+ "ceil" => NativeFunc.new("math.ceil", method(:math_ceil)),
66
+ "divide" => NativeFunc.new("math.divide", method(:math_divide)),
67
+ "sign" => NativeFunc.new("math.sign", method(:math_sign)),
68
+ "abs" => NativeFunc.new("math.abs", method(:math_abs)),
69
+ "rand" => NativeFunc.new("math.rand", method(:math_rand)),
70
+ "sin" => NativeFunc.new("math.sin", method(:math_sin)),
71
+ "cos" => NativeFunc.new("math.cos", method(:math_cos)),
72
+ "exp" => NativeFunc.new("math.exp", method(:math_exp)),
73
+ "log" => NativeFunc.new("math.log", method(:math_log)),
74
+ "pow" => NativeFunc.new("math.pow", method(:math_pow)),
75
+ "sqrt" => NativeFunc.new("math.sqrt", method(:math_sqrt)),
76
+ "pi" => Math::PI,
77
+ "e" => Math::E,
78
+ }
79
+ end
80
+
81
+ # --- math (reached as `@math.round`, `@math.divide`, `@math.pi`, …) ---
82
+
83
+ NUMBER_PAIR = ["[_ ? @Number, _ ? @Number]"].freeze
84
+ NUMBER_ARRAY = ['_ ? (xs => {"c": xs, "f": @Number} | @all)'].freeze
85
+ NUMBER = ["_ ? @Number"].freeze
86
+
87
+ # `round`/`floor`/`ceil` return an integer; a non-finite input is a math_error
88
+ # (their Ruby forms raise `FloatDomainError` on it).
89
+ def math_round(v)
90
+ return v if v.is_a?(ErrorVal)
91
+ return argument_error("math.round", v, NUMBER) unless numeric?(v)
92
+ return error("math_error", "math.round", v, "not a finite number") if non_finite?(v)
93
+
94
+ v.round
95
+ end
96
+
97
+ def math_floor(v)
98
+ return v if v.is_a?(ErrorVal)
99
+ return argument_error("math.floor", v, NUMBER) unless numeric?(v)
100
+ return error("math_error", "math.floor", v, "not a finite number") if non_finite?(v)
101
+
102
+ v.floor
103
+ end
104
+
105
+ def math_ceil(v)
106
+ return v if v.is_a?(ErrorVal)
107
+ return argument_error("math.ceil", v, NUMBER) unless numeric?(v)
108
+ return error("math_error", "math.ceil", v, "not a finite number") if non_finite?(v)
109
+
110
+ v.ceil
111
+ end
112
+
113
+ # `divide` always yields a float. Integer division is `@OP.quotient`, the
114
+ # remainder `@OP.modulo`.
115
+ def math_divide(v)
116
+ return v if v.is_a?(ErrorVal)
117
+ return argument_error("math.divide", v, NUMBER_PAIR) unless pair?(v) && numeric?(v[0]) && numeric?(v[1])
118
+ return error("math_error", "math.divide", v, "division by zero") if v[1] == 0
119
+
120
+ v[0].to_f / v[1]
121
+ end
122
+
123
+ # -1 / 0 / 1 (via `<`/`>`, so NaN → 0, never Ruby's `nil`).
124
+ def math_sign(v)
125
+ return v if v.is_a?(ErrorVal)
126
+ return argument_error("math.sign", v, NUMBER) unless numeric?(v)
127
+
128
+ if v < 0 then -1
129
+ elsif v > 0 then 1
130
+ else 0
131
+ end
132
+ end
133
+
134
+ def math_abs(v)
135
+ return v if v.is_a?(ErrorVal)
136
+ return argument_error("math.abs", v, NUMBER) unless numeric?(v)
137
+
138
+ v.abs
139
+ end
140
+
141
+ # `null` → a float in `[0.0, 1.0)`; a positive integer `n` → an integer in
142
+ # `[0, n)`.
143
+ def math_rand(v)
144
+ return v if v.is_a?(ErrorVal)
145
+ return rand if v == NULL
146
+ return rand(v) if integer?(v) && v.positive?
147
+
148
+ argument_error("math.rand", v, ["_ ? @Null", '_ ? (n ? @Integer => [0, n] | @OP.compare | (-1 => true))'])
149
+ end
150
+
151
+ def math_sin(v)
152
+ return v if v.is_a?(ErrorVal)
153
+ return argument_error("math.sin", v, NUMBER) unless numeric?(v)
154
+
155
+ Math.sin(v)
156
+ end
157
+
158
+ def math_cos(v)
159
+ return v if v.is_a?(ErrorVal)
160
+ return argument_error("math.cos", v, NUMBER) unless numeric?(v)
161
+
162
+ Math.cos(v)
163
+ end
164
+
165
+ def math_exp(v)
166
+ return v if v.is_a?(ErrorVal)
167
+ return argument_error("math.exp", v, NUMBER) unless numeric?(v)
168
+
169
+ Math.exp(v)
170
+ end
171
+
172
+ # Square root; the domain is non-negative numbers (a negative would be
173
+ # complex).
174
+ def math_sqrt(v)
175
+ return v if v.is_a?(ErrorVal)
176
+ return argument_error("math.sqrt", v, NUMBER) unless numeric?(v)
177
+ return error("math_error", "math.sqrt", v, "square root of a negative number") if v < 0
178
+
179
+ Math.sqrt(v)
180
+ end
181
+
182
+ # Natural logarithm; the domain is positive numbers (`Math.log` raises on a
183
+ # negative and gives `-Infinity` at 0).
184
+ def math_log(v)
185
+ return v if v.is_a?(ErrorVal)
186
+ return argument_error("math.log", v, NUMBER) unless numeric?(v)
187
+ return error("math_error", "math.log", v, "log of a non-positive number") if v <= 0
188
+
189
+ Math.log(v)
190
+ end
191
+
192
+ # `base ** exponent`: integer when the base and a non-negative integer
193
+ # exponent are integers, a float otherwise. A negative base with a fractional
194
+ # exponent (a complex result) is a math_error.
195
+ def math_pow(v)
196
+ return v if v.is_a?(ErrorVal)
197
+ return argument_error("math.pow", v, NUMBER_PAIR) unless pair?(v) && numeric?(v[0]) && numeric?(v[1])
198
+
199
+ a, b = v
200
+ result = a.is_a?(Integer) && b.is_a?(Integer) && !b.negative? ? a**b : a.to_f**b
201
+ return error("math_error", "math.pow", v, "not in domain (complex result)") if result.is_a?(Complex)
202
+
203
+ result
204
+ end
205
+
206
+ # --- OP: the sugar-target operators (reached as `@OP.sum`, `@OP.and`, …) ---
207
+ #
208
+ # The arithmetic, boolean, and equality members take an array of ANY length;
209
+ # the unary ones take a single value; `compare` returns -1 / 0 / 1. The
210
+ # friendly derived forms (`@lt`, `@gt`, `@truthy`, …) are stdlib files built on
211
+ # these.
212
+
213
+ def op_sum(v)
214
+ return v if v.is_a?(ErrorVal)
215
+ return argument_error("OP.sum", v, NUMBER_ARRAY) unless v.is_a?(Array) && v.all? { |x| numeric?(x) }
216
+
217
+ v.sum(0)
218
+ end
219
+
220
+ def op_product(v)
221
+ return v if v.is_a?(ErrorVal)
222
+ return argument_error("OP.product", v, NUMBER_ARRAY) unless v.is_a?(Array) && v.all? { |x| numeric?(x) }
223
+
224
+ v.reduce(1, :*)
225
+ end
226
+
227
+ def op_negate(v)
228
+ return v if v.is_a?(ErrorVal)
229
+ return argument_error("OP.negate", v, ["_ ? @Number"]) unless numeric?(v)
230
+
231
+ -v
232
+ end
233
+
234
+ # The unary reciprocal 1/x, always a float; 0 is a math_error.
235
+ def op_invert(v)
236
+ return v if v.is_a?(ErrorVal)
237
+ return argument_error("OP.invert", v, ["_ ? @Number"]) unless numeric?(v)
238
+ return error("math_error", "OP.invert", v, "division by zero") if v == 0
239
+
240
+ 1.0 / v
241
+ end
242
+
243
+ INTEGER_PAIR = ["[_ ? @Integer, _ ? @Integer]"].freeze
244
+
245
+ # Integer division and its remainder — integers only (`@divide` handles the
246
+ # float case). Ruby's `/` and `%` agree in sign, so `q*b + r == a` holds.
247
+ def op_quotient(v)
248
+ return v if v.is_a?(ErrorVal)
249
+ return argument_error("OP.quotient", v, INTEGER_PAIR) unless pair?(v) && integer?(v[0]) && integer?(v[1])
250
+ return error("math_error", "OP.quotient", v, "division by zero") if v[1] == 0
251
+
252
+ v[0] / v[1]
253
+ end
254
+
255
+ def op_modulo(v)
256
+ return v if v.is_a?(ErrorVal)
257
+ return argument_error("OP.modulo", v, INTEGER_PAIR) unless pair?(v) && integer?(v[0]) && integer?(v[1])
258
+ return error("math_error", "OP.modulo", v, "modulo by zero") if v[1] == 0
259
+
260
+ v[0] % v[1]
261
+ end
262
+
263
+ # Deep, exact equality across the whole array: true iff every element
264
+ # equals the first (so 0 and 1 elements are vacuously equal). Any types.
265
+ def op_equal(v)
266
+ return v if v.is_a?(ErrorVal)
267
+ return argument_error("OP.equal", v, ["_ ? @Array"]) unless v.is_a?(Array)
268
+
269
+ v.all? { |x| @interp.deep_equal?(v[0], x) }
270
+ end
271
+
272
+ COMPARE_PAIR = ["[_ ? @Number, _ ? @Number]", "[_ ? @String, _ ? @String]"].freeze
273
+
274
+ # Order two numbers or two strings: -1, 0, or 1 (no deep equality). Built on
275
+ # `<` rather than `<=>` so a NaN operand yields 0 (unordered), never Ruby's
276
+ # `nil` — NaN is a reachable value (`Infinity - Infinity`).
277
+ def op_compare(v)
278
+ return v if v.is_a?(ErrorVal)
279
+ return argument_error("OP.compare", v, COMPARE_PAIR) unless pair?(v)
280
+
281
+ a, b = v
282
+ unless (numeric?(a) && numeric?(b)) || (a.is_a?(String) && b.is_a?(String))
283
+ return argument_error("OP.compare", v, COMPARE_PAIR)
284
+ end
285
+
286
+ if a < b then -1
287
+ elsif b < a then 1
288
+ else 0
289
+ end
290
+ end
291
+
292
+ def op_and(v)
293
+ return v if v.is_a?(ErrorVal)
294
+ return argument_error("OP.and", v, ["_ ? @Array"]) unless v.is_a?(Array)
295
+
296
+ v.all? { |x| @interp.truthy?(x) }
297
+ end
298
+
299
+ def op_or(v)
300
+ return v if v.is_a?(ErrorVal)
301
+ return argument_error("OP.or", v, ["_ ? @Array"]) unless v.is_a?(Array)
302
+
303
+ v.any? { |x| @interp.truthy?(x) }
304
+ end
305
+
306
+ def op_not(v)
307
+ return v if v.is_a?(ErrorVal)
308
+
309
+ !@interp.truthy?(v)
310
+ end
311
+
312
+ # --- strings and structure bridges ---
313
+
314
+ def size(v)
315
+ return v if v.is_a?(ErrorVal)
316
+ return argument_error("size", v, ["_ ? @String", "_ ? @Array", "_ ? @Object"]) unless v.is_a?(String) || v.is_a?(Array) || v.is_a?(Hash)
317
+
318
+ v.length
319
+ end
320
+
321
+ # `[items, separator]`: join an array of strings into one string. `@concat`
322
+ # is the stdlib pair-case built on this.
323
+ def join(v)
324
+ return v if v.is_a?(ErrorVal)
325
+ expected = ['[_ ? (xs => {"c": xs, "f": @String} | @all), _ ? @String]']
326
+ return argument_error("join", v, expected) unless pair?(v)
327
+
328
+ array, separator = v
329
+ unless array.is_a?(Array) && separator.is_a?(String) && array.all? { |item| item.is_a?(String) }
330
+ return argument_error("join", v, expected)
331
+ end
332
+
333
+ array.join(separator)
334
+ end
335
+
336
+ # `[string, separator]`: the inverse of `@join`. Splits on the LITERAL
337
+ # separator (Ruby's `" "` whitespace special-case does not apply) and keeps
338
+ # empty fields; an empty separator splits into characters. `@chars` is the
339
+ # stdlib single-string case built on this.
340
+ def split(v)
341
+ return v if v.is_a?(ErrorVal)
342
+ expected = ["[_ ? @String, _ ? @String]"]
343
+ return argument_error("split", v, expected) unless pair?(v) && v[0].is_a?(String) && v[1].is_a?(String)
344
+
345
+ string, separator = v
346
+ return string.chars if separator.empty?
347
+
348
+ string.split(Regexp.new(Regexp.escape(separator)), -1)
349
+ end
350
+
351
+ def to_string(v)
352
+ return v if v.is_a?(ErrorVal)
353
+
354
+ case v
355
+ when String then v
356
+ when Integer, Float then v.to_s
357
+ when true then "true"
358
+ when false then "false"
359
+ when NULL then "null"
360
+ else error("conversion_error", "toString", v, "cannot stringify this value type")
361
+ end
362
+ end
363
+
364
+ def parse_number(v)
365
+ return v if v.is_a?(ErrorVal)
366
+ return argument_error("parseNumber", v, ["_ ? @String"]) unless v.is_a?(String)
367
+
368
+ case v
369
+ when /\A-?\d+\z/ then v.to_i
370
+ when /\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/ then v.to_f
371
+ else error("conversion_error", "parseNumber", v, "not a numeric string")
372
+ end
373
+ end
374
+
375
+ # --- object key enumeration (Tier 0: patterns can't enumerate unknown keys) ---
376
+
377
+ def keys(v)
378
+ return v if v.is_a?(ErrorVal)
379
+ return argument_error("keys", v, ["_ ? @Object"]) unless v.is_a?(Hash)
380
+
381
+ v.keys
382
+ end
383
+
384
+ def values(v)
385
+ return v if v.is_a?(ErrorVal)
386
+ return argument_error("values", v, ["_ ? @Object"]) unless v.is_a?(Hash)
387
+
388
+ v.values
389
+ end
390
+
391
+ def to_object(v)
392
+ return v if v.is_a?(ErrorVal)
393
+ # Each entry must be a [string, _] pair.
394
+ expected = ['_ ? (xs => {"c": xs, "f": ([_ ? @String, _] => true)} | @all)']
395
+ unless v.is_a?(Array) && v.all? { |entry| pair?(entry) && entry[0].is_a?(String) }
396
+ return argument_error("toObject", v, expected)
397
+ end
398
+
399
+ v.to_h
400
+ end
401
+
402
+ private
403
+
404
+ # Type predicates, also reused as internal guards.
405
+
406
+ def integer?(v)
407
+ v.is_a?(Integer) && !boolean?(v)
408
+ end
409
+
410
+ def float?(v)
411
+ v.is_a?(Float)
412
+ end
413
+
414
+ def numeric?(v)
415
+ v.is_a?(Numeric) && !boolean?(v)
416
+ end
417
+
418
+ def string?(v)
419
+ v.is_a?(String)
420
+ end
421
+
422
+ def boolean?(v)
423
+ v == true || v == false
424
+ end
425
+
426
+ def array?(v)
427
+ v.is_a?(Array)
428
+ end
429
+
430
+ def object?(v)
431
+ v.is_a?(Hash)
432
+ end
433
+
434
+ def null?(v)
435
+ v == NULL
436
+ end
437
+
438
+ def function?(v)
439
+ v.is_a?(Func) || v.is_a?(NativeFunc)
440
+ end
441
+
442
+ def pair?(v)
443
+ v.is_a?(Array) && v.length == 2
444
+ end
445
+
446
+ def non_finite?(v)
447
+ v.is_a?(Float) && !v.finite?
448
+ end
449
+
450
+ # Build a standardized interpreter error carrying a human-readable
451
+ # `message` (see docs/user/reference.md §6.5). Use this for failures that
452
+ # aren't an input-shape mismatch (math, conversion, access). `operation`
453
+ # is the builtin's own `@`-reference (`@#{name}`).
454
+ def error(kind, name, v, message)
455
+ ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{name}", input: v, message: message)
456
+ end
457
+
458
+ # Build an `argument_error` describing the acceptable inputs as a list of
459
+ # Fusion patterns. The input was unacceptable iff it matches none of them.
460
+ def argument_error(name, v, expected)
461
+ ErrorVal.from_runtime(kind: "argument_error", origin: "builtin", operation: "@#{name}", input: v, expected: expected)
462
+ end
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Interpreter internals ===
4
+ #
5
+ # Environment: maps names -> values, with a parent chain. Built-ins live at root.
6
+
7
+ module Fusion
8
+ class Interpreter
9
+ class Env
10
+ # Raised by #bind when a name is already bound in this env's own scope —
11
+ # i.e. a duplicate pattern binder like `[a, a]`. Interpreter#apply catches
12
+ # it and reports a binding_error (see docs/user/reference.md §6.5).
13
+ class DuplicateBinding < StandardError
14
+ attr_reader :name
15
+
16
+ def initialize(name)
17
+ @name = name
18
+ super("identifier already bound: #{name}")
19
+ end
20
+ end
21
+
22
+ attr_reader :parent
23
+
24
+ def initialize(parent = nil)
25
+ @vars = {} # pattern bindings, keyed by identifier
26
+ @context = {} # hidden interpreter context, keyed by symbol
27
+ @parent = parent
28
+ end
29
+
30
+ def child
31
+ Env.new(self)
32
+ end
33
+
34
+ # The topmost ancestor — the binding-free root a run is built on. The
35
+ # interpreter loads files relative to it, so they stay isolated.
36
+ def root
37
+ @parent ? @parent.root : self
38
+ end
39
+
40
+ # Pattern bindings:
41
+ # - Shadowing a binding from a parent Env is always allowed.
42
+ # - A duplicate identifier in the same Env is usually an error, but allowed on the REPL.
43
+ def bind(name, value, checked: true)
44
+ if checked && @vars.key?(name)
45
+ raise DuplicateBinding, name
46
+ end
47
+
48
+ @vars[name] = value
49
+ end
50
+
51
+ def lookup(name)
52
+ if @vars.key?(name)
53
+ @vars[name]
54
+ elsif @parent
55
+ @parent.lookup(name)
56
+ else
57
+ :__unbound__
58
+ end
59
+ end
60
+
61
+ # Hidden interpreter context:
62
+ # - `:dir`: the directory @-references resolve against (a path String).
63
+ # - `:file`: the current file's absolute path, used for error origins (a
64
+ # String; absent for inline/REPL code, which reports as origin
65
+ # "code" with file "<inline>").
66
+ # - `:self`: the current top-level unit's own Thunk, used for recursion via a bare `@`.
67
+ # - `:jail`: the run's jail root confining @-resolution (an absolute path
68
+ # String, or nil for unconfined). Set once on the root and, unlike
69
+ # the others, never overridden by a descendant.
70
+ # - `:call_site`: the innermost user-code `file` a stdlib body borrows for its
71
+ # errors (a String). Set on a stdlib function's clause env in
72
+ # Interpreter#apply; user/inline code derives its own and omits it.
73
+ def set_context(key, value)
74
+ @context[key] = value
75
+ self
76
+ end
77
+
78
+ def context(key)
79
+ if @context.key?(key)
80
+ @context[key]
81
+ elsif @parent
82
+ @parent.context(key)
83
+ else
84
+ :__unbound__
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Interpreter internals ===
4
+ #
5
+ # An error value, always carrying a payload (any JSON-like Fusion value).
6
+
7
+ module Fusion
8
+ class Interpreter
9
+ class ErrorVal
10
+ attr_reader :payload
11
+
12
+ def initialize(payload, runtime: false)
13
+ @payload = payload
14
+ @runtime = runtime
15
+ end
16
+
17
+ # Attach the call-site `file` to a runtime error. Idempotent.
18
+ def with_call_site(file)
19
+ # Only stamp runtime-produced errors. After this check we are sure that the payload wasn't user-constructed.
20
+ return self unless @runtime
21
+ raise Unreachable, "Unexpected runtime error payload: #{@payload}" unless @payload.is_a?(Hash)
22
+ # Don't double stamp. Idempotency.
23
+ return self if @payload.key?("file")
24
+ # Only stamp certain errors.
25
+ return self unless ["builtin", "stdlib"].include?(@payload["origin"])
26
+
27
+ # Insert "file" after "origin"
28
+ reordered = {}
29
+ @payload.each do |key, value|
30
+ reordered[key] = value
31
+ reordered["file"] = file if key == "origin"
32
+ end
33
+ @payload = reordered
34
+ self
35
+ end
36
+
37
+ # Was this error runtime-produced (as opposed to user-constructed via `!expr`)?
38
+ # Runtime errors use lenient serialization (docs/user/reference.md §9.3) and
39
+ # get a call-site `file` stamped.
40
+ def runtime?
41
+ @runtime
42
+ end
43
+
44
+ # Build a runtime-produced error with the standardized payload shape
45
+ # documented in docs/user/reference.md §6.5.
46
+ def self.from_runtime(kind:, origin:, operation:, input:, file: nil, expected: nil, message: nil)
47
+ raise Unreachable, "an error with `expected` must not also carry a `message`" if expected && message
48
+
49
+ received_error = input.is_a?(ErrorVal)
50
+
51
+ payload = { "kind" => kind, "origin" => origin }
52
+ payload["file"] = file if file
53
+ payload["operation"] = operation
54
+ payload["status"] = received_error ? 1 : 0
55
+ payload["input"] = received_error ? input.payload : input
56
+ payload["expected"] = expected if expected
57
+ payload["message"] = message if message
58
+
59
+ new(payload, runtime: true)
60
+ end
61
+
62
+ def inspect
63
+ "!#{payload.inspect}"
64
+ end
65
+
66
+ def to_s
67
+ inspect
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Interpreter internals ===
4
+ #
5
+ # A function closes over the environment in which it was defined.
6
+
7
+ module Fusion
8
+ class Interpreter
9
+ class Func
10
+ attr_reader :clauses, :env
11
+
12
+ def initialize(clauses, env)
13
+ @clauses = clauses # [AST::Clause, ...]
14
+ @env = env
15
+ end
16
+
17
+ def inspect
18
+ "<func/#{clauses.length}>"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # === Interpreter internals ===
4
+ #
5
+ # A native (Ruby-implemented) function. Apply treats it like a Func.
6
+
7
+ module Fusion
8
+ class Interpreter
9
+ class NativeFunc
10
+ attr_reader :name, :fn
11
+
12
+ def initialize(name, fn)
13
+ @name = name
14
+ @fn = fn
15
+ end
16
+
17
+ def inspect
18
+ "<builtin #{name}>"
19
+ end
20
+ end
21
+ end
22
+ end