Class: Fusion::Interpreter
- Inherits:
-
Object
- Object
- Fusion::Interpreter
- 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
-
#root_env ⇒ Object
readonly
Returns the value of attribute root_env.
Class Method Summary collapse
- .safe ⇒ Object
-
.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.
-
.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.
Instance Method Summary collapse
-
#apply(f, v, location = "interpreter") ⇒ Object
—- Application & matching —————————————— ‘location` is the “code X” where the `|` lives, used if `f` is not a function.
-
#apply_predicate(pred_expr, value, env) ⇒ Object
Run a guard predicate against the matched value.
-
#code_location(env) ⇒ Object
The error field ‘location` for code being evaluated under `env`.
- #deep_equal?(a, b) ⇒ Boolean
-
#eval_array(node, env) ⇒ Object
Array/object literals propagate any error encountered during construction.
-
#eval_expr(node, env) ⇒ Object
—- Expression evaluation ——————————————-.
- #eval_index(node, env) ⇒ Object
- #eval_member(node, env) ⇒ Object
- #eval_object(node, env) ⇒ Object
- #eval_pipe(node, env) ⇒ Object
- #evaluate_file(abspath) ⇒ Object
-
#file_location(abspath) ⇒ Object
The error field ‘location` for code at `abspath`.
-
#initialize(env_vars: nil) ⇒ Interpreter
constructor
A new instance of Interpreter.
-
#load_file(abspath) ⇒ Object
—- File loading —————————————————–.
-
#match(pattern, value, env) ⇒ Object
Binds matched sub-values into ‘env` as it goes.
- #match_array(pattern, value, env) ⇒ Object
- #match_object(pattern, value, env) ⇒ Object
-
#resolve_name(name, dir, location) ⇒ Object
Resolve a bare “@name”: sibling file > builtin (incl. load, ENV) > stdlib > !.
-
#resolve_path(relpath, dir) ⇒ Object
Resolve a pure path “@dir/a” or “@../a”: file only, never builtin/stdlib.
-
#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.
Constructor Details
#initialize(env_vars: nil) ⇒ Interpreter
Returns a new instance of Interpreter.
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.("../../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_env ⇒ Object (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
.safe ⇒ Object
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. ) 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. ) 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.) 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
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.) 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.(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.(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.(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.
578 579 580 |
# File 'lib/fusion/interpreter.rb', line 578 def truthy?(value) value != false && value != NULL end |