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/thunk.rb,
lib/fusion/interpreter/builtins.rb,
lib/fusion/interpreter/error_val.rb,
lib/fusion/interpreter/native_func.rb
Defined Under Namespace
Modules: Builtins Classes: Env, ErrorVal, Func, NativeFunc, Thunk
Constant Summary
Constants included from AST
AST::ArrayItem, AST::ArraySpread, AST::Clause, AST::Identifier, AST::KeyValuePair, AST::ObjectSpread, AST::PatternItem, AST::PatternPair, AST::PatternRest
Class Method Summary collapse
- .safe ⇒ Object
-
.safe_apply(function, input, environment) ⇒ 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, call_site = "<fusion>") ⇒ Object
---- Application & matching ------------------------------------------
call_siteis the innermost user-code file the application runs for (see #call_site): a built-in/stdlib error reports it as itsfile, and a stdlib function passes it on to the operations it calls. -
#apply_predicate(pred_expr, value, env) ⇒ Object
Run a guard predicate against the matched value.
-
#call_site(env) ⇒ Object
The
filean error here should carry: the innermost user-code file on the dynamic call chain. -
#code_site(env) ⇒ Object
The error fields
{origin:, file:}for code being evaluated underenv. - #deep_equal?(a, b) ⇒ Boolean
- #dispatch_apply(f, v, call_site) ⇒ Object
-
#display_path(abspath) ⇒ Object
A file path for error payloads: relative to the working directory, so a payload carries no machine-specific absolute prefix (and stays stable when a whole project is moved together).
-
#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_index_set(node, env) ⇒ Object
obj[idx = value]— returns a new array/object with one entry set (the setter counterpart of eval_index). - #eval_member(node, env) ⇒ Object
- #eval_object(node, env) ⇒ Object
- #eval_pipe(node, env) ⇒ Object
-
#evaluate_file(abspath) ⇒ Object
Compute the file's value.
-
#evaluate_unit(ast) ⇒ Object
Evaluate a top-level unit that has no file of its own: - inline source (
-e) - REPL entries. -
#file_site(abspath) ⇒ Object
The error site (
{origin:, file?:}) for code atabspath. -
#initialize(env, env_vars: nil) ⇒ Interpreter
constructor
envis the run's environment, passed in externally and stored as@env. - #inside?(abspath, root) ⇒ Boolean
- #jail_error(site, operation, input) ⇒ Object
-
#load_file(abspath) ⇒ Object
---- File loading -----------------------------------------------------
Thunkdoes cycle-detection and result-memoization. -
#match(pattern, value, env) ⇒ Object
Binds matched sub-values into
envas it goes. - #match_array(pattern, value, env) ⇒ Object
- #match_object(pattern, value, env) ⇒ Object
-
#resolve_builtin_or_stdlib(name, dir, site, reference) ⇒ Object
The non-sibling tail of @name resolution: builtin (incl. load, ENV) > stdlib > !.
-
#resolve_name(name, dir, site, reference) ⇒ Object
Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
-
#resolve_path(relpath, dir, site, reference) ⇒ Object
Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
-
#resolve_super(env, dir, site) ⇒ Object
Resolve "@@": the builtin/stdlib that the referencing file shadows.
-
#root_env ⇒ Object
The binding-free root the run is built on — computed on demand.
-
#truthy?(value) ⇒ Boolean
---- Equality & helpers ---------------------------------------------- Ruby-style truthiness:
falseandnullare falsey, everything else (numbers, strings, arrays, objects, functions — including0and"") is truthy. -
#within_jail?(abspath) ⇒ Boolean
The run's jail (the
:jailcontext, an absolute path or nil) confines file-backed @-resolution to its subtree.
Constructor Details
#initialize(env, env_vars: nil) ⇒ Interpreter
env is the run's environment, passed in externally and stored as @env.
Its :jail context confines @-resolution, and its topmost ancestor
(@env.root) is the binding-free root that loaded files are isolated against.
The stdlib always stays reachable.
46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/fusion/interpreter.rb', line 46 def initialize(env, env_vars: nil) @stdlib_dir = File.("../../stdlib", __dir__) raise Unreachable, "Couldn't find standard library" unless Dir.exist?(@stdlib_dir) @env = env @env_vars = env_vars || ENV.to_h @file_cache = {} # abspath -> Thunk @ast_cache = {} # abspath -> AST @builtins = {} # name -> NativeFunc (consulted by @name, not via env) Builtins.install(@builtins, self) end |
Class Method Details
.safe ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/fusion/interpreter.rb', line 78 def self.safe yield rescue Unreachable # An interpreter bug. Allowed to surface. raise rescue StandardError => err Interpreter::ErrorVal.from_runtime( kind: "internal_error", origin: "interpreter", operation: "running the program", input: NULL, message: err. ) rescue SystemExit # Let exit/abort through. raise rescue SystemStackError Interpreter::ErrorVal.from_runtime( kind: "limit_error", origin: "interpreter", operation: "running the program", input: NULL, message: "stack level too deep" ) rescue Exception => err # rubocop:disable Lint/RescueException # Final net: any other escaped Ruby error becomes a payloaded error too. Interpreter::ErrorVal.from_runtime( kind: "internal_error", origin: "interpreter", operation: "running the program", input: NULL, message: err. ) end |
.safe_apply(function, input, environment) ⇒ 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.
62 63 64 65 66 |
# File 'lib/fusion/interpreter.rb', line 62 def self.safe_apply(function, input, environment) safe do new(environment).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.
72 73 74 75 76 |
# File 'lib/fusion/interpreter.rb', line 72 def self.safe_evaluate(expression, environment) safe do new(environment).evaluate_unit(expression) end end |
Instance Method Details
#apply(f, v, call_site = "<fusion>") ⇒ Object
---- Application & matching ------------------------------------------
call_site is the innermost user-code file the application runs for (see
#call_site): a built-in/stdlib error reports it as its file, and a stdlib
function passes it on to the operations it calls. It defaults to the runtime
("
513 514 515 516 517 518 519 520 |
# File 'lib/fusion/interpreter.rb', line 513 def apply(f, v, call_site = "<fusion>") result = dispatch_apply(f, v, call_site) # The interpreter owns the call-site `file` of a standardized builtin/stdlib # error (the call site is its knowledge, not the stdlib's): stamp it here, at # the apply that produced the error. An error keeps the file from its # innermost apply, so outer applies leave it untouched (see #with_call_site). result.is_a?(ErrorVal) ? result.with_call_site(call_site) : result 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.
588 589 590 591 592 593 594 595 |
# File 'lib/fusion/interpreter.rb', line 588 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, call_site(env)) else apply(eval_expr(pred_expr, env), value, call_site(env)) end end |
#call_site(env) ⇒ Object
The file an error here should carry: the innermost user-code file on the
dynamic call chain. When stdlib code runs, it borrows the user call site that
reached it (injected as :call_site in #apply); user/inline code is its own
file (derived from code_site); above any user code, the runtime: "
145 146 147 148 149 150 151 |
# File 'lib/fusion/interpreter.rb', line 145 def call_site(env) injected = env.context(:call_site) return injected unless injected == :__unbound__ site = code_site(env) site[:origin] == "code" ? site[:file] : "<fusion>" end |
#code_site(env) ⇒ Object
The error fields {origin:, file:} for code being evaluated under env.
131 132 133 134 135 136 137 138 139 |
# File 'lib/fusion/interpreter.rb', line 131 def code_site(env) f = env.context(:file) if f == :__unbound__ # Inline (`-e`) programs and REPL entries report an "<inline>" file. { origin: "code", file: "<inline>" } else file_site(f) end end |
#deep_equal?(a, b) ⇒ Boolean
739 740 741 742 743 744 745 746 747 748 749 750 |
# File 'lib/fusion/interpreter.rb', line 739 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 |
#dispatch_apply(f, v, call_site) ⇒ Object
522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 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 573 574 575 576 577 578 579 580 581 |
# File 'lib/fusion/interpreter.rb', line 522 def dispatch_apply(f, v, call_site) 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 # TODO: move math errors into the builtins. This should become a safety net for unpredicted errors. kind = (err.is_a?(FloatDomainError) || err.is_a?(ZeroDivisionError) || err.is_a?(Math::DomainError)) ? "math_error" : "internal_error" ErrorVal.from_runtime(kind: kind, origin: "builtin", operation: "@#{f.name}", input: v, message: err.) end elsif f.is_a?(Func) # Stdlib code has no user file of its own: errors inside it (and in the # built-ins it calls) report the user `call_site` that reached it. User and # inline functions are their own call site (derived lexically from their env). body_call_site = (code_site(f.env)[:origin] == "stdlib") ? call_site : nil 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 clause_env.set_context(:call_site, body_call_site) if body_call_site m = begin match(clause.pattern, v, clause_env) rescue Env::DuplicateBinding => e return ErrorVal.from_runtime(kind: "binding_error", **code_site(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.from_runtime(kind: "argument_error", origin: "code", file: call_site, operation: "|", input: [v, f], expected: ["[_, _ ? @Function]"]) end end |
#display_path(abspath) ⇒ Object
A file path for error payloads: relative to the working directory, so a payload carries no machine-specific absolute prefix (and stays stable when a whole project is moved together).
113 114 115 116 117 |
# File 'lib/fusion/interpreter.rb', line 113 def display_path(abspath) Pathname.new(abspath).relative_path_from(Dir.pwd).to_s rescue ArgumentError abspath # no relative path exists (e.g. different roots) — keep the absolute 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.
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/fusion/interpreter.rb', line 362 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.from_runtime(kind: "argument_error", **code_site(env), operation: "[...] array spread", input: value, expected: ["_ ? @Array"]) end else raise Unreachable, "Unknown array item #{item.class}" end end out end |
#eval_expr(node, env) ⇒ Object
---- Expression evaluation -------------------------------------------
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 |
# File 'lib/fusion/interpreter.rb', line 296 def eval_expr(node, env) case node when Expression::Lit then node.value when Expression::ErrLit # Mark errors from within the stdlib as runtime-produced. runtime = code_site(env)[:origin] == "stdlib" if node.payload.nil? # Bare `!` means `!null` ErrorVal.new(NULL, runtime: runtime) else payload = eval_expr(node.payload, env) if payload.is_a?(ErrorVal) # No nested errors. Propagate inner error. payload else ErrorVal.new(payload, runtime: runtime) end end when Expression::Ident value = env.lookup(node.name) if value == :__unbound__ ErrorVal.from_runtime(kind: "binding_error", **code_site(env), operation: "reading identifier #{node.name}", input: node.name, message: "unbound identifier") else value end when Expression::FileRef dir = env.context(:dir) dir = Dir.pwd if dir == :__unbound__ case node.variety when :self # Bare `@` is the value of the current top-level unit: a file, or an inline (`-e`)/REPL entry. self_thunk = env.context(:self) if self_thunk == :__unbound__ raise Unreachable, "bare @ evaluated outside a top-level unit" end self_thunk.force(operation: "@", input: NULL, site: code_site(env)) when :super resolve_super(env, dir, code_site(env)) when :super_name # `@@name`: the stable builtin/stdlib `name`, skipping any sibling shadow. resolve_builtin_or_stdlib(node.path, dir, code_site(env), "@@#{node.path}") when :name resolve_name(node.path, dir, code_site(env), "@#{node.path}") else # :path resolve_path(node.path, dir, code_site(env), "@#{node.path}") 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) when Expression::IndexSet then eval_index_set(node, env) else raise Unreachable, "Unknown AST node #{node.class}" end end |
#eval_index(node, env) ⇒ Object
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 |
# File 'lib/fusion/interpreter.rb', line 444 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 site = code_site(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.from_runtime(kind: "access_error", **site, operation: "[]", 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.from_runtime(kind: "access_error", **site, operation: "[]", input: [obj, idx], message: "missing key") end else ErrorVal.from_runtime(kind: "argument_error", **site, operation: "[]", input: [obj, idx], expected: ["[_ ? @Array, _ ? @Integer]", "[_ ? @Object, _ ? @String]"]) end end |
#eval_index_set(node, env) ⇒ Object
obj[idx = value] — returns a new array/object with one entry set (the setter
counterpart of eval_index). An array index must already exist (arrays are not
extended; negative indices count from the end); an object key may be new. The
original obj is unchanged.
482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 |
# File 'lib/fusion/interpreter.rb', line 482 def eval_index_set(node, env) obj = eval_expr(node.obj, env) return obj if obj.is_a?(ErrorVal) idx = eval_expr(node.idx, env) return idx if idx.is_a?(ErrorVal) value = eval_expr(node.value, env) return value if value.is_a?(ErrorVal) site = code_site(env) if obj.is_a?(Array) && idx.is_a?(Integer) i = idx >= 0 ? idx : obj.length + idx if i >= 0 && i < obj.length obj.dup.tap { |copy| copy[i] = value } else ErrorVal.from_runtime(kind: "access_error", **site, operation: "[=]", input: [obj, idx, value], message: "index out of range") end elsif obj.is_a?(Hash) && idx.is_a?(String) obj.merge(idx => value) else ErrorVal.from_runtime(kind: "argument_error", **site, operation: "[=]", input: [obj, idx, value], expected: ["[_ ? @Array, _ ? @Integer, _]", "[_ ? @Object, _ ? @String, _]"]) end end |
#eval_member(node, env) ⇒ Object
424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 |
# File 'lib/fusion/interpreter.rb', line 424 def eval_member(node, env) obj = eval_expr(node.obj, env) if obj.is_a?(ErrorVal) # Propagate errors return obj end site = code_site(env) unless obj.is_a?(Hash) return ErrorVal.from_runtime(kind: "argument_error", **site, operation: ".#{node.key}", input: obj, expected: ["_ ? @Object"]) end unless obj.key?(node.key) return ErrorVal.from_runtime(kind: "access_error", **site, operation: ".#{node.key}", input: obj, message: "missing key") end obj[node.key] end |
#eval_object(node, env) ⇒ Object
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 |
# File 'lib/fusion/interpreter.rb', line 390 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.from_runtime(kind: "argument_error", **code_site(env), operation: "{...} object spread", input: value, expected: ["_ ? @Object"]) end else raise Unreachable, "Unknown object pair #{pair.class}" end end out end |
#eval_pipe(node, env) ⇒ Object
418 419 420 421 422 |
# File 'lib/fusion/interpreter.rb', line 418 def eval_pipe(node, env) value = eval_expr(node.left, env) function = eval_expr(node.right, env) apply(function, value, call_site(env)) end |
#evaluate_file(abspath) ⇒ Object
Compute the file's value. Use only within Thunk and raise Thunk::ReadFailure
for unreadable files.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/fusion/interpreter.rb', line 155 def evaluate_file(abspath) ast = (@ast_cache[abspath] ||= begin src = File.read(abspath) Parser.parse_file(src, site: file_site(abspath)) end) if ast.is_a?(ErrorVal) # a parse error (already a payloaded value) ast else env = root_env.child env.set_context(:dir, File.dirname(abspath)) # for resolving @-refs env.set_context(:file, abspath) # for error sites env.set_context(:self, load_file(abspath)) # for `@` self-recursion eval_expr(ast, env) end rescue Errno::ENOENT raise Thunk::ReadFailure, "file not found" rescue SystemCallError => err # EISDIR, EACCES, ... (file-system access failures) # Drop Ruby's "@ io_fread - <path>" tail. raise Thunk::ReadFailure, err..split(" @ ").first.downcase end |
#evaluate_unit(ast) ⇒ Object
Evaluate a top-level unit that has no file of its own:
- inline source (
-e) - REPL entries
180 181 182 183 184 185 186 187 188 |
# File 'lib/fusion/interpreter.rb', line 180 def evaluate_unit(ast) # Evaluate in a child of `@env`, so we don't mutate it. The child inherits # `@env`'s bindings (only non-empty in the REPL), `:dir`, and jail. unit_env = @env.child thunk = Thunk.new { eval_expr(ast, unit_env) } unit_env.set_context(:self, thunk) # for `@` self-recursion thunk.force end |
#file_site(abspath) ⇒ Object
The error site ({origin:, file?:}) for code at abspath. stdlib is part
of the core language, so its internal filenames are never exposed; only
user code carries a file.
122 123 124 125 126 127 128 |
# File 'lib/fusion/interpreter.rb', line 122 def file_site(abspath) if abspath.start_with?(@stdlib_dir + File::SEPARATOR) { origin: "stdlib" } else { origin: "code", file: display_path(abspath) } end end |
#inside?(abspath, root) ⇒ Boolean
286 287 288 289 |
# File 'lib/fusion/interpreter.rb', line 286 def inside?(abspath, root) root = root.chomp(File::SEPARATOR) abspath == root || abspath.start_with?(root + File::SEPARATOR) end |
#jail_error(site, operation, input) ⇒ Object
291 292 293 |
# File 'lib/fusion/interpreter.rb', line 291 def jail_error(site, operation, input) ErrorVal.from_runtime(kind: "reference_error", **site, operation: operation, input: input, message: "outside the jail") end |
#load_file(abspath) ⇒ Object
---- File loading -----------------------------------------------------
Thunk does cycle-detection and result-memoization.
106 107 108 |
# File 'lib/fusion/interpreter.rb', line 106 def load_file(abspath) @file_cache[abspath] ||= Thunk.new { evaluate_file(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.
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 |
# File 'lib/fusion/interpreter.rb', line 600 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
661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 |
# File 'lib/fusion/interpreter.rb', line 661 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
700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 |
# File 'lib/fusion/interpreter.rb', line 700 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_builtin_or_stdlib(name, dir, site, reference) ⇒ Object
The non-sibling tail of @name resolution: builtin (incl. load, ENV) > stdlib > !.
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 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/fusion/interpreter.rb', line 218 def resolve_builtin_or_stdlib(name, dir, site, reference) 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| # @load errors carry origin "builtin" and no `file`; #apply stamps the # call site (the user file that wrote `| @load`) onto them as `file`. site = { origin: "builtin", file: nil } unless v.is_a?(String) next ErrorVal.from_runtime(kind: "argument_error", **site, operation: "@load", input: v, expected: ["_ ? @String"]) end target = File.(v, d) # Check the jail before touching the filesystem, so an out-of-jail # path can't be probed for existence. next jail_error(site, "@load", v) unless within_jail?(target) unless File.exist?(target) next ErrorVal.from_runtime(kind: "reference_error", **site, operation: "@load", input: v, message: "file not found") end load_file(target).force(operation: "@load", input: v, site: site) end) end if @builtins.key?(name) return @builtins[name] end stdlib_file = File.join(@stdlib_dir, name + ".fsn") if File.exist?(stdlib_file) # The reference reports the user's source text, not the internal stdlib path. return load_file(stdlib_file).force(operation: reference, input: NULL, site: site) end ErrorVal.from_runtime(kind: "reference_error", **site, operation: reference, input: NULL, message: "unresolved reference") end |
#resolve_name(name, dir, site, reference) ⇒ Object
Resolve a bare "@name": sibling file > builtin (incl. load, ENV) > stdlib > !.
site is the {origin:, file:} of the referencing code; reference is its
own source text ("@name") — the single operation every failure reports.
193 194 195 196 197 198 199 200 201 202 |
# File 'lib/fusion/interpreter.rb', line 193 def resolve_name(name, dir, site, reference) sibling_file = File.(name + ".fsn", dir) if File.exist?(sibling_file) return jail_error(site, reference, NULL) unless within_jail?(sibling_file) return load_file(sibling_file).force(operation: reference, input: NULL, site: site) end resolve_builtin_or_stdlib(name, dir, site, reference) end |
#resolve_path(relpath, dir, site, reference) ⇒ Object
Resolve a pure path "@dir/a" or "@../a": file only, never builtin/stdlib.
reference is the source text ("@../a"), the single operation reported.
265 266 267 268 269 270 |
# File 'lib/fusion/interpreter.rb', line 265 def resolve_path(relpath, dir, site, reference) target = File.(relpath + ".fsn", dir) return jail_error(site, reference, NULL) unless within_jail?(target) load_file(target).force(operation: reference, input: NULL, site: site) end |
#resolve_super(env, dir, site) ⇒ Object
Resolve "@@": the builtin/stdlib that the referencing file shadows. It is its
own name resolved with the sibling step skipped (the sibling is itself), so
the file can extend what it overrides. There is no file to take super of in
an inline (-e) or REPL entry.
208 209 210 211 212 213 214 215 |
# File 'lib/fusion/interpreter.rb', line 208 def resolve_super(env, dir, site) file = env.context(:file) if file == :__unbound__ return ErrorVal.from_runtime(kind: "reference_error", **site, operation: "@@", input: NULL, message: "no enclosing file") end resolve_builtin_or_stdlib(File.basename(file, ".fsn"), dir, site, "@@") end |
#root_env ⇒ Object
The binding-free root the run is built on — computed on demand. Loaded files are isolated against it (see evaluate_file).
38 39 40 |
# File 'lib/fusion/interpreter.rb', line 38 def root_env @env.root 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.
735 736 737 |
# File 'lib/fusion/interpreter.rb', line 735 def truthy?(value) value != false && value != NULL end |
#within_jail?(abspath) ⇒ Boolean
The run's jail (the :jail context, an absolute path or nil) confines
file-backed @-resolution to its subtree. The stdlib is always reachable (it
lives outside any project), and a nil/unset jail means unconfined. Containment
is lexical (expand_path normalises ..) and follows existing symlinks: it
confines references, it is not a security sandbox and needs none — Fusion
cannot write files, so no symlink can be planted to escape.
278 279 280 281 282 283 284 |
# File 'lib/fusion/interpreter.rb', line 278 def within_jail?(abspath) jail = @env.context(:jail) return true if jail.nil? || jail == :__unbound__ return true if inside?(abspath, @stdlib_dir) inside?(abspath, jail) end |