fusion-lang 0.0.1.alpha1 → 0.0.1.alpha2

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.
@@ -0,0 +1,595 @@
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_relative "ast"
22
+ require_relative "null"
23
+ require_relative "interpreter/error_val"
24
+ require_relative "interpreter/func"
25
+ require_relative "interpreter/native_func"
26
+ require_relative "interpreter/builtins"
27
+ require_relative "interpreter/env"
28
+ require_relative "interpreter/file_thunk"
29
+
30
+ module Fusion
31
+ class Interpreter
32
+ include AST
33
+
34
+ attr_reader :root_env
35
+
36
+ def initialize(env_vars: nil)
37
+ @stdlib_dir = File.expand_path("../../stdlib", __dir__)
38
+ raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir)
39
+
40
+ @env_vars = env_vars || ENV.to_h
41
+ @file_cache = {} # abspath -> FileThunk
42
+ @ast_cache = {} # abspath -> AST
43
+ @builtins = {} # name -> NativeFunc (consulted by @name, not via env)
44
+ Builtins.install(@builtins, self)
45
+ @root_env = Env.new # holds no builtins now; bare identifiers are holes only
46
+ end
47
+
48
+ # Apply the program to one input behind a safety net: a Ruby-level failure
49
+ # (notably a stack overflow) becomes a payloaded error rather than a raw
50
+ # backtrace, so the stdout/stderr contract always holds. In the stream the
51
+ # error is one record's output and the next line continues.
52
+ def self.safe_apply(function, input)
53
+ safe do
54
+ new.apply(function, input)
55
+ end
56
+ end
57
+
58
+ # Evaluate an expression behind the same per-run safety net as
59
+ # exe/fusion, so a Ruby-level failure becomes a printed payload and the
60
+ # session survives it. A statement carries its expression; a bare
61
+ # expression entry is the expression itself.
62
+ def self.safe_evaluate(expression, environment)
63
+ safe do
64
+ new.eval_expr(expression, environment)
65
+ end
66
+ end
67
+
68
+ def self.safe
69
+ yield
70
+ rescue Unreachable
71
+ # An interpreter bug. Allowed to surface.
72
+ 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
78
+ )
79
+ rescue SystemExit
80
+ # Let exit/abort through.
81
+ raise
82
+ rescue SystemStackError
83
+ Interpreter::ErrorVal.internal(
84
+ kind: "stack_error", location: "interpreter", operation: "running the program",
85
+ input: NULL, message: "recursion too deep"
86
+ )
87
+ rescue Exception => err # rubocop:disable Lint/RescueException
88
+ # 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
93
+ )
94
+ end
95
+
96
+ # ---- File loading -----------------------------------------------------
97
+ def load_file(abspath)
98
+ @file_cache[abspath] ||= FileThunk.new(self, abspath)
99
+ end
100
+
101
+ # The error field `location` for code at `abspath`.
102
+ def file_location(abspath)
103
+ if abspath.start_with?(@stdlib_dir + File::SEPARATOR)
104
+ "stdlib #{File.basename(abspath)}"
105
+ else
106
+ "code #{File.basename(abspath)}"
107
+ end
108
+ end
109
+
110
+ # The error field `location` for code being evaluated under `env`.
111
+ def code_location(env)
112
+ f = env.lookup("__file__")
113
+ if f == :__unbound__
114
+ # Inline (`-e`) programs have no file, so they report as "code <inline>".
115
+ "code <inline>"
116
+ else
117
+ file_location(f)
118
+ end
119
+ end
120
+
121
+ def evaluate_file(abspath)
122
+ loc = file_location(abspath)
123
+ ast = (@ast_cache[abspath] ||= begin
124
+ src = File.read(abspath)
125
+ Parser.parse_file(src, location: loc)
126
+ end)
127
+
128
+ if ast.is_a?(ErrorVal) # a parse error (already a payloaded value)
129
+ ast
130
+ 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)
136
+ eval_expr(ast, env)
137
+ end
138
+ 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)
142
+ end
143
+
144
+ # 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)
148
+ if File.exist?(sibling_file)
149
+ return load_file(sibling_file).force
150
+ end
151
+
152
+ if name == "ENV"
153
+ return @env_vars.dup
154
+ end
155
+
156
+ if name == "load"
157
+ # @load is a builtin closure capturing the calling file's directory. It
158
+ # loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
159
+ d = dir
160
+ return NativeFunc.new("load", lambda do |v|
161
+ unless v.is_a?(String)
162
+ next ErrorVal.internal(kind: "type_error", location: "builtin load", operation: "@load", input: v, message: "expected a string")
163
+ end
164
+
165
+ target = File.expand_path(v, d)
166
+
167
+ unless File.exist?(target)
168
+ next ErrorVal.internal(kind: "reference_error", location: "builtin load", operation: "@load", input: v, message: "file not found")
169
+ end
170
+
171
+ load_file(target).force
172
+ end)
173
+ end
174
+
175
+ if @builtins.key?(name)
176
+ return @builtins[name]
177
+ end
178
+
179
+ stdlib_file = File.join(@stdlib_dir, name + ".fsn")
180
+ if File.exist?(stdlib_file)
181
+ return load_file(stdlib_file).force
182
+ end
183
+
184
+ ErrorVal.internal(kind: "reference_error", location: location, operation: "resolving @#{name}", input: name, message: "unresolved reference")
185
+ end
186
+
187
+ # 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
190
+ end
191
+
192
+ # ---- Expression evaluation -------------------------------------------
193
+ def eval_expr(node, env)
194
+ case node
195
+ when Expression::Lit then node.value
196
+ when Expression::ErrLit
197
+ if node.payload.nil?
198
+ # Bare `!` means `!null`
199
+ ErrorVal.new(NULL)
200
+ else
201
+ payload = eval_expr(node.payload, env)
202
+
203
+ if payload.is_a?(ErrorVal)
204
+ # No nested errors. Propagate inner error.
205
+ payload
206
+ else
207
+ ErrorVal.new(payload)
208
+ end
209
+ end
210
+ when Expression::Ident
211
+ value = env.lookup(node.name)
212
+
213
+ if value == :__unbound__
214
+ ErrorVal.internal(kind: "binding_error", location: code_location(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
215
+ else
216
+ value
217
+ end
218
+ when Expression::FileRef
219
+ dir = env.lookup("__dir__")
220
+ dir = Dir.pwd if dir == :__unbound__
221
+ case node.variety
222
+ 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__")
227
+
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
232
+ end
233
+ when :name
234
+ resolve_name(node.path, dir, code_location(env))
235
+ else # :path
236
+ resolve_path(node.path, dir)
237
+ end
238
+ when Expression::ArrLit then eval_array(node, env)
239
+ when Expression::ObjLit then eval_object(node, env)
240
+ when Expression::FuncLit then Func.new(node.clauses, env)
241
+ when Expression::Pipe then eval_pipe(node, env)
242
+ when Expression::Member then eval_member(node, env)
243
+ when Expression::Index then eval_index(node, env)
244
+ else
245
+ raise Unreachable, "Unknown AST node #{node.class}"
246
+ end
247
+ end
248
+
249
+ # Array/object literals propagate any error encountered during construction.
250
+ # Errors are not first-class: at any point during execution there is either
251
+ # a value or an error in motion, never both.
252
+ def eval_array(node, env)
253
+ out = []
254
+
255
+ node.items.each do |item|
256
+ value = eval_expr(item.value, env)
257
+
258
+ if value.is_a?(ErrorVal)
259
+ # Propagate errors
260
+ return value
261
+ end
262
+
263
+ case item
264
+ when ArrayItem
265
+ out.append(value)
266
+ 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")
271
+ end
272
+ else
273
+ raise Unreachable, "Unknown array item #{item.class}"
274
+ end
275
+ end
276
+
277
+ out
278
+ end
279
+
280
+ def eval_object(node, env)
281
+ out = {}
282
+
283
+ node.pairs.each do |pair|
284
+ value = eval_expr(pair.value, env)
285
+
286
+ if value.is_a?(ErrorVal)
287
+ # Propagate errors
288
+ return value
289
+ end
290
+
291
+ case pair
292
+ when KeyValuePair
293
+ out[pair.key] = value
294
+ 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")
299
+ end
300
+ else
301
+ raise Unreachable, "Unknown object pair #{pair.class}"
302
+ end
303
+ end
304
+
305
+ out
306
+ end
307
+
308
+ def eval_pipe(node, env)
309
+ value = eval_expr(node.left, env)
310
+ function = eval_expr(node.right, env)
311
+ apply(function, value, code_location(env))
312
+ end
313
+
314
+ def eval_member(node, env)
315
+ obj = eval_expr(node.obj, env)
316
+
317
+ if obj.is_a?(ErrorVal)
318
+ # Propagate errors
319
+ return obj
320
+ end
321
+
322
+ loc = code_location(env)
323
+ 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")
325
+ end
326
+
327
+ unless obj.key?(node.key)
328
+ return ErrorVal.internal(kind: "access_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "missing key")
329
+ end
330
+
331
+ obj[node.key]
332
+ end
333
+
334
+ def eval_index(node, env)
335
+ obj = eval_expr(node.obj, env)
336
+
337
+ if obj.is_a?(ErrorVal)
338
+ # Propagate errors
339
+ return obj
340
+ end
341
+
342
+ idx = eval_expr(node.idx, env)
343
+
344
+ if idx.is_a?(ErrorVal)
345
+ # Propagate errors
346
+ return idx
347
+ end
348
+
349
+ loc = code_location(env)
350
+ if obj.is_a?(Array) && idx.is_a?(Integer)
351
+ i = idx >= 0 ? idx : obj.length + idx
352
+ if i >= 0 && i < obj.length
353
+ obj[i]
354
+ else
355
+ ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx}]", input: [obj, idx], message: "index out of range")
356
+ end
357
+ elsif obj.is_a?(Hash) && idx.is_a?(String)
358
+ if obj.key?(idx)
359
+ obj[idx]
360
+ else
361
+ ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx.inspect}]", input: [obj, idx], message: "missing key")
362
+ end
363
+ else
364
+ ErrorVal.internal(kind: "type_error", location: loc, operation: "[index]", input: [obj, idx], message: "bad index type")
365
+ end
366
+ end
367
+
368
+ # ---- 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")
373
+ if f.is_a?(ErrorVal)
374
+ # Propagate errors
375
+ return f
376
+ end
377
+
378
+ if f.is_a?(NativeFunc)
379
+ if v.is_a?(ErrorVal)
380
+ # Uniform propagation: built-ins never receive errors as inputs.
381
+ return v
382
+ end
383
+
384
+ # Safety net: a builtin that raises a Ruby error (e.g. a domain error)
385
+ # becomes a payloaded error rather than a raw backtrace on stderr.
386
+ begin
387
+ 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)
391
+ end
392
+ elsif f.is_a?(Func)
393
+ f.clauses.each do |clause|
394
+ # Bindings are inserted directly into a fresh child env as the pattern
395
+ # matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
396
+ # convert to a binding_error here. A failed/abandoned clause just drops
397
+ # its env, so partial bindings never leak.
398
+ clause_env = f.env.child
399
+ m = begin
400
+ match(clause.pattern, v, clause_env)
401
+ 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")
403
+ end
404
+
405
+ if m.is_a?(ErrorVal)
406
+ # A `?` predicate raised an error during matching: bubble it up as the
407
+ # function's return value (no further clauses are tried).
408
+ return m
409
+ elsif m
410
+ # Successful match
411
+ return eval_expr(clause.body, clause_env)
412
+ else
413
+ # Try next pattern
414
+ next
415
+ end
416
+ end
417
+ # No clause matched. If the input was an error, it keeps propagating
418
+ # (an unmatched error must never be silently swallowed). Otherwise the
419
+ # lenient default is `null`.
420
+ v.is_a?(ErrorVal) ? v : NULL
421
+ else
422
+ ErrorVal.internal(kind: "type_error", location: location, operation: "|", input: [v, f], message: "applied a non-function")
423
+ end
424
+ end
425
+
426
+ # Run a guard predicate against the matched value. The predicate is a `|`
427
+ # pipeline of functions; the value enters at the leftmost stage and the result
428
+ # flows through each stage, so `a ? b | c` evaluates `a | b | c`. A non-pipe
429
+ # predicate is just the single-stage case. #apply propagates any ErrorVal in
430
+ # either the function or the threaded value position.
431
+ def apply_predicate(pred_expr, value, env)
432
+ if pred_expr.is_a?(Expression::Pipe)
433
+ upstream = apply_predicate(pred_expr.left, value, env)
434
+ apply(eval_expr(pred_expr.right, env), upstream, code_location(env))
435
+ else
436
+ apply(eval_expr(pred_expr, env), value, code_location(env))
437
+ end
438
+ end
439
+
440
+ # Binds matched sub-values into `env` as it goes. Returns true (match),
441
+ # false (no match), or an ErrorVal (predicate errored). A duplicate binder
442
+ # raises Env::DuplicateBinding, caught in #apply.
443
+ def match(pattern, value, env)
444
+ case pattern
445
+ when Pattern::PLit
446
+ deep_equal?(pattern.value, value)
447
+ when Pattern::PErr
448
+ if value.is_a?(ErrorVal)
449
+ # The pattern.inner is always a non-`!` pattern (ensured by the parser)
450
+ match(pattern.inner, value.payload, env)
451
+ else
452
+ false
453
+ end
454
+ when Pattern::PWild
455
+ # `_` matches anything EXCEPT an error value.
456
+ !value.is_a?(ErrorVal)
457
+ when Pattern::PBind
458
+ if value.is_a?(ErrorVal)
459
+ # binders never capture an error
460
+ false
461
+ else
462
+ env.bind(pattern.name, value)
463
+ true
464
+ end
465
+ when Pattern::PArr
466
+ match_array(pattern, value, env)
467
+ when Pattern::PObj
468
+ match_object(pattern, value, env)
469
+ when Pattern::PGuard
470
+ inner_res = match(pattern.inner, value, env)
471
+ if !inner_res
472
+ # The inner pattern didn't match
473
+ false
474
+ elsif inner_res.is_a?(ErrorVal)
475
+ # The inner pattern produced an error
476
+ inner_res
477
+ else
478
+ # The predicate evaluates in the clause's lexical env — `env.parent`, not
479
+ # `env` — so it cannot see the pattern's own binders (including the one it
480
+ # refines). `env` is the clause env created in #apply, threaded through
481
+ # matching unchanged, so its parent is always that lexical env.
482
+ lexical_env = env.parent
483
+
484
+ # The predicate is a pipeline fed the matched value: `a ? b | c` tests
485
+ # `a | b | c`. The value reaching this PGuard is already correct, since
486
+ # `!pat ? pred` parses as PErr(PGuard(pat, pred)) — by now it is the
487
+ # payload. #apply_predicate threads it through each `|` stage.
488
+ predicate_result = apply_predicate(pattern.pred_expr, value, lexical_env)
489
+ if predicate_result.is_a?(ErrorVal)
490
+ # An unresolved @-reference, or an error raised while applying the
491
+ # predicate, becomes the clause's result.
492
+ return predicate_result
493
+ else
494
+ # Ruby-style truthiness: the clause matches unless the predicate
495
+ # yields `false` or `null`.
496
+ truthy?(predicate_result)
497
+ end
498
+ end
499
+ else
500
+ raise Unreachable, "Unknown pattern #{pattern.class}"
501
+ end
502
+ end
503
+
504
+ def match_array(pattern, value, env)
505
+ return false unless value.is_a?(Array)
506
+
507
+ items = pattern.items
508
+ rest_index = items.index { |e| e.is_a?(PatternRest) }
509
+
510
+ if rest_index.nil?
511
+ return false unless value.length == items.length
512
+
513
+ items.each_with_index do |item, i|
514
+ r = match(item.pattern, value[i], env)
515
+ return r if r.is_a?(ErrorVal)
516
+ return false unless r
517
+ end
518
+ true
519
+ else
520
+ before = items[0...rest_index]
521
+ after = items[(rest_index + 1)..]
522
+ return false if value.length < before.length + after.length
523
+ before.each_with_index do |item, i|
524
+ r = match(item.pattern, value[i], env)
525
+ return r if r.is_a?(ErrorVal)
526
+ return false unless r
527
+ end
528
+ after.each_with_index do |item, k|
529
+ vi = value.length - after.length + k
530
+ r = match(item.pattern, value[vi], env)
531
+ return r if r.is_a?(ErrorVal)
532
+ return false unless r
533
+ end
534
+ rest_name = items[rest_index].name
535
+ if rest_name
536
+ mid = value[before.length...(value.length - after.length)]
537
+ env.bind(rest_name, mid)
538
+ end
539
+ true
540
+ end
541
+ end
542
+
543
+ def match_object(pattern, value, env)
544
+ return false unless value.is_a?(Hash)
545
+
546
+ matched_keys = []
547
+ rest_name = :__none__
548
+ pattern.pairs.each do |pair|
549
+ case pair
550
+ when PatternRest
551
+ rest_name = pair.name # may be nil (ignore) or a string
552
+ when PatternPair
553
+ return false unless value.key?(pair.key)
554
+ r = match(pair.pattern, value[pair.key], env)
555
+ return r if r.is_a?(ErrorVal)
556
+ return false unless r
557
+ matched_keys << pair.key
558
+ else
559
+ raise Unreachable, "Unknown object pattern pair #{pair.class}"
560
+ end
561
+ end
562
+ case rest_name
563
+ when :__none__
564
+ # No `...rest`: the pattern is closed — a superfluous key means no match.
565
+ return false unless value.size == matched_keys.size
566
+ when nil
567
+ # Bare `...`: extra keys are allowed but bound to nothing.
568
+ else
569
+ env.bind(rest_name, value.reject { |k, _| matched_keys.include?(k) })
570
+ end
571
+ true
572
+ end
573
+
574
+ # ---- Equality & helpers ----------------------------------------------
575
+ # Ruby-style truthiness: `false` and `null` are falsey, everything else
576
+ # (numbers, strings, arrays, objects, functions — including `0` and `""`) is
577
+ # truthy. Used by `?` guards and the `@and` / `@or` / `@not` built-ins.
578
+ def truthy?(value)
579
+ value != false && value != NULL
580
+ end
581
+
582
+ def deep_equal?(a, b)
583
+ return true if a.equal?(b)
584
+ return false if a.class != b.class
585
+ case a
586
+ when Array
587
+ a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
588
+ when Hash
589
+ a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
590
+ else
591
+ a == b
592
+ end
593
+ end
594
+ end
595
+ end