Class: Fusion::Interpreter

Inherits:
Object
  • Object
show all
Includes:
AST
Defined in:
lib/fusion/interpreter.rb,
lib/fusion/interpreter/env.rb,
lib/fusion/interpreter/func.rb,
lib/fusion/interpreter/builtins.rb,
lib/fusion/interpreter/error_val.rb,
lib/fusion/interpreter/file_thunk.rb,
lib/fusion/interpreter/native_func.rb

Defined Under Namespace

Modules: Builtins Classes: Env, ErrorVal, FileThunk, Func, NativeFunc

Constant Summary

Constants included from AST

AST::ArrayItem, AST::ArraySpread, AST::Clause, AST::Identifier, AST::KeyValuePair, AST::ObjectSpread, AST::PatternItem, AST::PatternPair, AST::PatternRest

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env_vars: nil) ⇒ Interpreter

Returns a new instance of Interpreter.

Raises:



36
37
38
39
40
41
42
43
44
45
46
# File 'lib/fusion/interpreter.rb', line 36

def initialize(env_vars: nil)
  @stdlib_dir = File.expand_path("../../stdlib", __dir__)
  raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir)

  @env_vars = env_vars || ENV.to_h
  @file_cache = {} # abspath -> FileThunk
  @ast_cache = {}  # abspath -> AST
  @builtins = {}   # name -> NativeFunc  (consulted by @name, not via env)
  Builtins.install(@builtins, self)
  @root_env = Env.new # holds no builtins now; bare identifiers are holes only
end

Instance Attribute Details

#root_envObject (readonly)

Returns the value of attribute root_env.



34
35
36
# File 'lib/fusion/interpreter.rb', line 34

def root_env
  @root_env
end

Class Method Details

.safeObject



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/fusion/interpreter.rb', line 68

def self.safe
  yield
rescue Unreachable
  # An interpreter bug. Allowed to surface.
  raise
rescue StandardError => err
  # TODO: change type
  Interpreter::ErrorVal.internal(
    kind: "type_error", location: "interpreter", operation: "running the program",
    input: NULL, message: err.message
  )
rescue SystemExit
  # Let exit/abort through.
  raise
rescue SystemStackError
  Interpreter::ErrorVal.internal(
    kind: "stack_error", location: "interpreter", operation: "running the program",
    input: NULL, message: "recursion too deep"
  )
rescue Exception => err # rubocop:disable Lint/RescueException
  # Final net: any other escaped Ruby error becomes a payloaded error too.
  # TODO: change type
  Interpreter::ErrorVal.internal(
    kind: "type_error", location: "interpreter", operation: "running the program",
    input: NULL, message: err.message
  )
end

.safe_apply(function, input) ⇒ Object

Apply the program to one input behind a safety net: a Ruby-level failure (notably a stack overflow) becomes a payloaded error rather than a raw backtrace, so the stdout/stderr contract always holds. In the stream the error is one record’s output and the next line continues.



52
53
54
55
56
# File 'lib/fusion/interpreter.rb', line 52

def self.safe_apply(function, input)
  safe do
    new.apply(function, input)
  end
end

.safe_evaluate(expression, environment) ⇒ Object

Evaluate an expression behind the same per-run safety net as exe/fusion, so a Ruby-level failure becomes a printed payload and the session survives it. A statement carries its expression; a bare expression entry is the expression itself.



62
63
64
65
66
# File 'lib/fusion/interpreter.rb', line 62

def self.safe_evaluate(expression, environment)
  safe do
    new.eval_expr(expression, environment)
  end
end

Instance Method Details

#apply(f, v, location = "interpreter") ⇒ Object

—- Application & matching —————————————— ‘location` is the “code X” where the `|` lives, used if `f` is not a function. It defaults to “interpreter” for apply calls with no code context (e.g. the CLI applying the whole program).



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/fusion/interpreter.rb', line 372

def apply(f, v, location = "interpreter")
  if f.is_a?(ErrorVal)
    # Propagate errors
    return f
  end

  if f.is_a?(NativeFunc)
    if v.is_a?(ErrorVal)
      # Uniform propagation: built-ins never receive errors as inputs.
      return v
    end

    # Safety net: a builtin that raises a Ruby error (e.g. a domain error)
    # becomes a payloaded error rather than a raw backtrace on stderr.
    begin
      f.fn.call(v)
    rescue StandardError => err
      kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError)) ? "math_error" : "type_error"
      ErrorVal.internal(kind: kind, location: "builtin #{f.name}", operation: f.name, input: v, message: err.message)
    end
  elsif f.is_a?(Func)
    f.clauses.each do |clause|
      # Bindings are inserted directly into a fresh child env as the pattern
      # matches; a duplicate binder (e.g. `[a, a]`) trips Env#bind, which we
      # convert to a binding_error here. A failed/abandoned clause just drops
      # its env, so partial bindings never leak.
      clause_env = f.env.child
      m = begin
        match(clause.pattern, v, clause_env)
      rescue Env::DuplicateBinding => e
        return ErrorVal.internal(kind: "binding_error", location: code_location(clause_env), operation: "binding identifier #{e.name}", input: e.name, message: "identifier already bound")
      end

      if m.is_a?(ErrorVal)
        # A `?` predicate raised an error during matching: bubble it up as the
        # function's return value (no further clauses are tried).
        return m
      elsif m
        # Successful match
        return eval_expr(clause.body, clause_env)
      else
        # Try next pattern
        next
      end
    end
    # No clause matched. If the input was an error, it keeps propagating
    # (an unmatched error must never be silently swallowed). Otherwise the
    # lenient default is `null`.
    v.is_a?(ErrorVal) ? v : NULL
  else
    ErrorVal.internal(kind: "type_error", location: location, operation: "|", input: [v, f], message: "applied a non-function")
  end
end

#apply_predicate(pred_expr, value, env) ⇒ Object

Run a guard predicate against the matched value. The predicate is a ‘|` pipeline of functions; the value enters at the leftmost stage and the result flows through each stage, so `a ? b | c` evaluates `a | b | c`. A non-pipe predicate is just the single-stage case. #apply propagates any ErrorVal in either the function or the threaded value position.



431
432
433
434
435
436
437
438
# File 'lib/fusion/interpreter.rb', line 431

def apply_predicate(pred_expr, value, env)
  if pred_expr.is_a?(Expression::Pipe)
    upstream = apply_predicate(pred_expr.left, value, env)
    apply(eval_expr(pred_expr.right, env), upstream, code_location(env))
  else
    apply(eval_expr(pred_expr, env), value, code_location(env))
  end
end

#code_location(env) ⇒ Object

The error field ‘location` for code being evaluated under `env`.



111
112
113
114
115
116
117
118
119
# File 'lib/fusion/interpreter.rb', line 111

def code_location(env)
  f = env.lookup("__file__")
  if f == :__unbound__
    # Inline (`-e`) programs have no file, so they report as "code <inline>".
    "code <inline>"
  else
    file_location(f)
  end
end

#deep_equal?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


582
583
584
585
586
587
588
589
590
591
592
593
# File 'lib/fusion/interpreter.rb', line 582

def deep_equal?(a, b)
  return true if a.equal?(b)
  return false if a.class != b.class
  case a
  when Array
    a.length == b.length && a.each_index.all? { |i| deep_equal?(a[i], b[i]) }
  when Hash
    a.length == b.length && a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
  else
    a == b
  end
end

#eval_array(node, env) ⇒ Object

Array/object literals propagate any error encountered during construction. Errors are not first-class: at any point during execution there is either a value or an error in motion, never both.



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/fusion/interpreter.rb', line 252

def eval_array(node, env)
  out = []

  node.items.each do |item|
    value = eval_expr(item.value, env)

    if value.is_a?(ErrorVal)
      # Propagate errors
      return value
    end

    case item
    when ArrayItem
      out.append(value)
    when ArraySpread
      if value.is_a?(Array)
        out.concat(value)
      else
        return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "[...] array spread", input: value, message: "expected an array")
      end
    else
      raise Unreachable, "Unknown array item #{item.class}"
    end
  end

  out
end

#eval_expr(node, env) ⇒ Object

—- Expression evaluation ——————————————-



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/fusion/interpreter.rb', line 193

def eval_expr(node, env)
  case node
  when Expression::Lit then node.value
  when Expression::ErrLit
    if node.payload.nil?
      # Bare `!` means `!null`
      ErrorVal.new(NULL)
    else
      payload = eval_expr(node.payload, env)

      if payload.is_a?(ErrorVal)
        # No nested errors. Propagate inner error.
        payload
      else
        ErrorVal.new(payload)
      end
    end
  when Expression::Ident
    value = env.lookup(node.name)

    if value == :__unbound__
      ErrorVal.internal(kind: "binding_error", location: code_location(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier")
    else
      value
    end
  when Expression::FileRef
    dir = env.lookup("__dir__")
    dir = Dir.pwd if dir == :__unbound__
    case node.variety
    when :self
      # Bare `@` is the current file. NOTE: inline (`-e`) programs have no
      # current file, so `@` is unresolvable there today — but it *should*
      # refer to the whole inline program (tracked as a gap).
      file = env.lookup("__file__")

      if file == :__unbound__
        ErrorVal.internal(kind: "reference_error", location: code_location(env), operation: "resolving @", input: NULL, message: "no current file for self-reference")
      else
        load_file(file).force
      end
    when :name
      resolve_name(node.path, dir, code_location(env))
    else # :path
      resolve_path(node.path, dir)
    end
  when Expression::ArrLit then eval_array(node, env)
  when Expression::ObjLit then eval_object(node, env)
  when Expression::FuncLit then Func.new(node.clauses, env)
  when Expression::Pipe then eval_pipe(node, env)
  when Expression::Member then eval_member(node, env)
  when Expression::Index then eval_index(node, env)
  else
    raise Unreachable, "Unknown AST node #{node.class}"
  end
end

#eval_index(node, env) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/fusion/interpreter.rb', line 334

def eval_index(node, env)
  obj = eval_expr(node.obj, env)

  if obj.is_a?(ErrorVal)
    # Propagate errors
    return obj
  end

  idx = eval_expr(node.idx, env)

  if idx.is_a?(ErrorVal)
    # Propagate errors
    return idx
  end

  loc = code_location(env)
  if obj.is_a?(Array) && idx.is_a?(Integer)
    i = idx >= 0 ? idx : obj.length + idx
    if i >= 0 && i < obj.length
      obj[i]
    else
      ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx}]", input: [obj, idx], message: "index out of range")
    end
  elsif obj.is_a?(Hash) && idx.is_a?(String)
    if obj.key?(idx)
      obj[idx]
    else
      ErrorVal.internal(kind: "access_error", location: loc, operation: "[#{idx.inspect}]", input: [obj, idx], message: "missing key")
    end
  else
    ErrorVal.internal(kind: "type_error", location: loc, operation: "[index]", input: [obj, idx], message: "bad index type")
  end
end

#eval_member(node, env) ⇒ Object



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/fusion/interpreter.rb', line 314

def eval_member(node, env)
  obj = eval_expr(node.obj, env)

  if obj.is_a?(ErrorVal)
    # Propagate errors
    return obj
  end

  loc = code_location(env)
  unless obj.is_a?(Hash)
    return ErrorVal.internal(kind: "type_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "expected an object")
  end

  unless obj.key?(node.key)
    return ErrorVal.internal(kind: "access_error", location: loc, operation: ".#{node.key}", input: [obj, node.key], message: "missing key")
  end

  obj[node.key]
end

#eval_object(node, env) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/fusion/interpreter.rb', line 280

def eval_object(node, env)
  out = {}

  node.pairs.each do |pair|
    value = eval_expr(pair.value, env)

    if value.is_a?(ErrorVal)
      # Propagate errors
      return value
    end

    case pair
    when KeyValuePair
      out[pair.key] = value
    when ObjectSpread
      if value.is_a?(Hash)
        out.merge!(value)
      else
        return ErrorVal.internal(kind: "type_error", location: code_location(env), operation: "{...} object spread", input: value, message: "expected an object")
      end
    else
      raise Unreachable, "Unknown object pair #{pair.class}"
    end
  end

  out
end

#eval_pipe(node, env) ⇒ Object



308
309
310
311
312
# File 'lib/fusion/interpreter.rb', line 308

def eval_pipe(node, env)
  value = eval_expr(node.left, env)
  function = eval_expr(node.right, env)
  apply(function, value, code_location(env))
end

#evaluate_file(abspath) ⇒ Object



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/fusion/interpreter.rb', line 121

def evaluate_file(abspath)
  loc = file_location(abspath)
  ast = (@ast_cache[abspath] ||= begin
    src = File.read(abspath)
    Parser.parse_file(src, location: loc)
  end)

  if ast.is_a?(ErrorVal) # a parse error (already a payloaded value)
    ast
  else
    # A file's value is evaluated in a fresh env whose parent is root (builtins),
    # plus knowledge of its own directory for resolving @refs.
    env = @root_env.child
    env.define("__dir__", File.dirname(abspath))
    env.define("__file__", abspath)
    eval_expr(ast, env)
  end
rescue Errno::ENOENT
  ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: "file not found")
rescue SystemCallError => err # EISDIR, EACCES, ... — file-system access failures
  ErrorVal.internal(kind: "reference_error", location: loc, operation: "reading file", input: abspath, message: err.message)
end

#file_location(abspath) ⇒ Object

The error field ‘location` for code at `abspath`.



102
103
104
105
106
107
108
# File 'lib/fusion/interpreter.rb', line 102

def file_location(abspath)
  if abspath.start_with?(@stdlib_dir + File::SEPARATOR)
    "stdlib #{File.basename(abspath)}"
  else
    "code #{File.basename(abspath)}"
  end
end

#load_file(abspath) ⇒ Object

—- File loading —————————————————–



97
98
99
# File 'lib/fusion/interpreter.rb', line 97

def load_file(abspath)
  @file_cache[abspath] ||= FileThunk.new(self, abspath)
end

#match(pattern, value, env) ⇒ Object

Binds matched sub-values into ‘env` as it goes. Returns true (match), false (no match), or an ErrorVal (predicate errored). A duplicate binder raises Env::DuplicateBinding, caught in #apply.



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'lib/fusion/interpreter.rb', line 443

def match(pattern, value, env)
  case pattern
  when Pattern::PLit
    deep_equal?(pattern.value, value)
  when Pattern::PErr
    if value.is_a?(ErrorVal)
      # The pattern.inner is always a non-`!` pattern (ensured by the parser)
      match(pattern.inner, value.payload, env)
    else
      false
    end
  when Pattern::PWild
    # `_` matches anything EXCEPT an error value.
    !value.is_a?(ErrorVal)
  when Pattern::PBind
    if value.is_a?(ErrorVal)
      # binders never capture an error
      false
    else
      env.bind(pattern.name, value)
      true
    end
  when Pattern::PArr
    match_array(pattern, value, env)
  when Pattern::PObj
    match_object(pattern, value, env)
  when Pattern::PGuard
    inner_res = match(pattern.inner, value, env)
    if !inner_res
      # The inner pattern didn't match
      false
    elsif inner_res.is_a?(ErrorVal)
      # The inner pattern produced an error
      inner_res
    else
      # The predicate evaluates in the clause's lexical env — `env.parent`, not
      # `env` — so it cannot see the pattern's own binders (including the one it
      # refines). `env` is the clause env created in #apply, threaded through
      # matching unchanged, so its parent is always that lexical env.
      lexical_env = env.parent

      # The predicate is a pipeline fed the matched value: `a ? b | c` tests
      # `a | b | c`. The value reaching this PGuard is already correct, since
      # `!pat ? pred` parses as PErr(PGuard(pat, pred)) — by now it is the
      # payload. #apply_predicate threads it through each `|` stage.
      predicate_result = apply_predicate(pattern.pred_expr, value, lexical_env)
      if predicate_result.is_a?(ErrorVal)
        # An unresolved @-reference, or an error raised while applying the
        # predicate, becomes the clause's result.
        return predicate_result
      else
        # Ruby-style truthiness: the clause matches unless the predicate
        # yields `false` or `null`.
        truthy?(predicate_result)
      end
    end
  else
    raise Unreachable, "Unknown pattern #{pattern.class}"
  end
end

#match_array(pattern, value, env) ⇒ Object



504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
# File 'lib/fusion/interpreter.rb', line 504

def match_array(pattern, value, env)
  return false unless value.is_a?(Array)

  items = pattern.items
  rest_index = items.index { |e| e.is_a?(PatternRest) }

  if rest_index.nil?
    return false unless value.length == items.length

    items.each_with_index do |item, i|
      r = match(item.pattern, value[i], env)
      return r if r.is_a?(ErrorVal)
      return false unless r
    end
    true
  else
    before = items[0...rest_index]
    after  = items[(rest_index + 1)..]
    return false if value.length < before.length + after.length
    before.each_with_index do |item, i|
      r = match(item.pattern, value[i], env)
      return r if r.is_a?(ErrorVal)
      return false unless r
    end
    after.each_with_index do |item, k|
      vi = value.length - after.length + k
      r = match(item.pattern, value[vi], env)
      return r if r.is_a?(ErrorVal)
      return false unless r
    end
    rest_name = items[rest_index].name
    if rest_name
      mid = value[before.length...(value.length - after.length)]
      env.bind(rest_name, mid)
    end
    true
  end
end

#match_object(pattern, value, env) ⇒ Object



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/fusion/interpreter.rb', line 543

def match_object(pattern, value, env)
  return false unless value.is_a?(Hash)

  matched_keys = []
  rest_name = :__none__
  pattern.pairs.each do |pair|
    case pair
    when PatternRest
      rest_name = pair.name # may be nil (ignore) or a string
    when PatternPair
      return false unless value.key?(pair.key)
      r = match(pair.pattern, value[pair.key], env)
      return r if r.is_a?(ErrorVal)
      return false unless r
      matched_keys << pair.key
    else
      raise Unreachable, "Unknown object pattern pair #{pair.class}"
    end
  end
  case rest_name
  when :__none__
    # No `...rest`: the pattern is closed — a superfluous key means no match.
    return false unless value.size == matched_keys.size
  when nil
    # Bare `...`: extra keys are allowed but bound to nothing.
  else
    env.bind(rest_name, value.reject { |k, _| matched_keys.include?(k) })
  end
  true
end

#resolve_name(name, dir, location) ⇒ Object

Resolve a bare “@name”: sibling file > builtin (incl. load, ENV) > stdlib > !. ‘location` is the “code X” of the referencing file (for the unresolved case).



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/fusion/interpreter.rb', line 146

def resolve_name(name, dir, location)
  sibling_file = File.expand_path(name + ".fsn", dir)
  if File.exist?(sibling_file)
    return load_file(sibling_file).force
  end

  if name == "ENV"
    return @env_vars.dup
  end

  if name == "load"
    # @load is a builtin closure capturing the calling file's directory. It
    # loads a VERBATIM filename (no ".fsn" appended) so arbitrary names work.
    d = dir
    return NativeFunc.new("load", lambda do |v|
      unless v.is_a?(String)
        next ErrorVal.internal(kind: "type_error", location: "builtin load", operation: "@load", input: v, message: "expected a string")
      end

      target = File.expand_path(v, d)

      unless File.exist?(target)
        next ErrorVal.internal(kind: "reference_error", location: "builtin load", operation: "@load", input: v, message: "file not found")
      end

      load_file(target).force
    end)
  end

  if @builtins.key?(name)
    return @builtins[name]
  end

  stdlib_file = File.join(@stdlib_dir, name + ".fsn")
  if File.exist?(stdlib_file)
    return load_file(stdlib_file).force
  end

  ErrorVal.internal(kind: "reference_error", location: location, operation: "resolving @#{name}", input: name, message: "unresolved reference")
end

#resolve_path(relpath, dir) ⇒ Object

Resolve a pure path “@dir/a” or “@../a”: file only, never builtin/stdlib.



188
189
190
# File 'lib/fusion/interpreter.rb', line 188

def resolve_path(relpath, dir)
  load_file(File.expand_path(relpath + ".fsn", dir)).force
end

#truthy?(value) ⇒ Boolean

—- Equality & helpers ———————————————- Ruby-style truthiness: ‘false` and `null` are falsey, everything else (numbers, strings, arrays, objects, functions — including `0` and `“”`) is truthy. Used by `?` guards and the `@and` / `@or` / `@not` built-ins.

Returns:

  • (Boolean)


578
579
580
# File 'lib/fusion/interpreter.rb', line 578

def truthy?(value)
  value != false && value != NULL
end