Logos.Eval (Logos v0.2.0)

Copy Markdown

The tree-walking evaluator: eval(form, env, runtime). Implements exactly six special forms -- quote, cond, do, def, fn, try -- every other list-headed form is a function-position call (evaluate head, evaluate args, apply).

Return convention

eval/3 returns the evaluated value directly, raising Logos.EvalError on a primitive-level failure (division by zero, an unbound symbol, a wrong-arity call, ...) rather than returning an {:error, reason} tuple -- this lets Logos-level try/catch (see eval_try/3) catch these the same way it already catches an explicit (throw ...) (Logos.Thrown), matching real Clojure's own try/catch being able to catch any runtime error, not just an explicit throw. Logos.eval_string/3's outermost boundary rescues both exception types back into {:error, reason}, so the public top-level API's contract ({:ok, value, env} | {:error, reason}) is completely unchanged -- this is purely an internal propagation mechanism.

Raising doesn't interact with BEAM tail-call elimination on the success path -- what breaks TCO is a try block wrapping a recursive call, and none gets added to the hot recursive path here (eval/3, eval_body/3, eval_cond/3, apply_user_fn/4); those simply stop threading a return-value tuple and let a raise propagate on its own, same as Logos.MacroError already does through Logos.Macroexpand. The two places that actually rescue stay exactly where they already were: eval_try/3 itself (a special form, user-code-triggered -- real Clojure/Java also give up TCO inside a try-body, an accepted trade-off, not a regression) and Logos.eval_string/3's own outermost boundary (one call, not recursive). See test/logos/tco_test.exs's 10,000,000-iteration test.

Namespaces / Vars

def interns a Logos.Var into the current namespace of runtime (Logos.Runtime.current_ns/1), via Logos.Namespace.intern!/5 -- every def is scoped to a namespace, not a single bare global symbol table, so two different namespaces can define the same bare name without colliding.

Symbol resolution (resolve_symbol/3/resolve_symbol_location/2) follows this order:

  • bare x -- lexical Logos.Env chain, then the current namespace's own vars, then its refers, then (since every namespace except logos.core itself implicitly refers it) logos.core's public vars, then unbound-symbol error.
  • qualified ns-or-alias/x -- ns-or-alias resolved against the current namespace's aliases first, falling back to treating it as a literal full namespace name, then x looked up directly in that namespace's own vars only -- a qualified reference never follows the target namespace's own refers, only its directly-interned vars.

Once a Var's location ({ns, var_name}) is found, its value is read via get_var_value_dynamic_aware/3 rather than Logos.Namespace.get_var_value/3 directly: a ^:dynamic Var currently binding-bound in the calling BEAM process (see priv/stdlib/core.logos's binding macro and Logos.Primitives' push-thread-binding!/pop-thread-binding!) resolves to its process-local override instead of its namespace-global root value. A Var that was never binding-bound (the overwhelming majority) is completely unaffected -- this is a no-op lookup for it.

Primitives

Elixir-implemented primitives (Logos.Primitives, (args, runtime) calling convention) are interned as ordinary {:primitive, name}-valued Vars in logos.core by Logos.Primitives.install!/1 at Logos.Runtime.new/1 time, so a primitive is looked up through exactly the same symbol-resolution path as anything else -- apply_fn/3 below just recognizes the {:primitive, name} marker value and dispatches to Logos.Primitives.apply/3. There is deliberately no primitive-specific code path in resolve_symbol/3 itself.

Tail calls / TCO

The core performance guarantee: a call in tail position is compiled by BEAM as a genuine Elixir tail call, so self-recursive Logos functions run in constant stack space. The tail positions here are: the last form of a do body (eval_body/3's single-element clause), a taken cond branch (eval_cond/3), and the last form of a fn body during application (apply_user_fn/4, again via eval_body/3). Every one of those is written as a bare call to eval/3 (or to another function that itself ends in such a call) with no enclosing case/with that transforms the result afterward. runtime is threaded everywhere as a plain extra positional argument on already-tail calls, never a wrapper around one -- see test/logos/tco_test.exs for a 10,000,000-iteration test validating this (in addition to test/logos/eval_test.exs's own smaller sanity checks).

Summary

Functions

Applies an already-evaluated callable to already-evaluated arg_values, dispatching on what f is: a %Fn{} closure runs through apply_user_fn/4 (clause matching, then a tail-called body); a {:primitive, name} marker delegates to Logos.Primitives.apply/3; a {:host_fn, mod, fun} wrapper calls straight into Elixir, converting any raised exception into Logos.EvalError; anything else raises Logos.EvalError with reason {:not_callable, other}.

Logos.Macroexpand's entry point for calling a macro's own private apply_macro/4 helper against the macro's underlying %Logos.Fn{} -- identical to the %Fn{} clause of apply_fn/3 except extra_bindings (a plain %{String.t() => term()} map, here always %{"&form" => ..., "&env" => ...}) is bound into the call's body environment in addition to the declared params, giving every macro the implicit &form/&env bindings real Clojure macros have.

A fresh, empty root Logos.Env -- lexical scope only. Kept as a named function (rather than requiring every call site to spell Logos.Env.new()) for readability; seeds no primitives -- those live in runtime's logos.core namespace, not Logos.Env.

Evaluates form (already macroexpanded -- Logos.Eval does not call Logos.Macroexpand itself) against lexical env and runtime's namespace registry, returning the value directly. Raises Logos.EvalError on a primitive-level failure -- see moduledoc for the return convention and the tail-call structure.

Resolves sym to a {ns, var_name} location without consulting the lexical Logos.Env chain -- this is the namespace-only half of resolution, shared by resolve_symbol/3 (which tries Env first) and Logos.Macroexpand's own private macro_lookup_by_resolution/2 (called only once that module's private macro_lookup/3 has already confirmed the head symbol isn't a shadowed lexical local -- this function itself has no locals set to check, since it's never given an Env). Still returns {:ok, _} | :error rather than raising -- unlike resolve_symbol/3, "not found" is an ordinary, expected outcome here (both callers use it as a plain lookup, not a "this must succeed" operation), not a failure to propagate.

Lisp truthiness: everything except nil and false is truthy.

Functions

apply_fn(f, arg_values, runtime)

@spec apply_fn(term(), [term()], Logos.Runtime.t()) :: term()

Applies an already-evaluated callable to already-evaluated arg_values, dispatching on what f is: a %Fn{} closure runs through apply_user_fn/4 (clause matching, then a tail-called body); a {:primitive, name} marker delegates to Logos.Primitives.apply/3; a {:host_fn, mod, fun} wrapper calls straight into Elixir, converting any raised exception into Logos.EvalError; anything else raises Logos.EvalError with reason {:not_callable, other}.

apply_macro_fn(f, arg_values, extra_bindings, runtime)

@spec apply_macro_fn(
  Logos.Fn.t(),
  [term()],
  %{required(String.t()) => term()},
  Logos.Runtime.t()
) ::
  term()

Logos.Macroexpand's entry point for calling a macro's own private apply_macro/4 helper against the macro's underlying %Logos.Fn{} -- identical to the %Fn{} clause of apply_fn/3 except extra_bindings (a plain %{String.t() => term()} map, here always %{"&form" => ..., "&env" => ...}) is bound into the call's body environment in addition to the declared params, giving every macro the implicit &form/&env bindings real Clojure macros have.

base_env()

@spec base_env() :: Logos.Env.t()

A fresh, empty root Logos.Env -- lexical scope only. Kept as a named function (rather than requiring every call site to spell Logos.Env.new()) for readability; seeds no primitives -- those live in runtime's logos.core namespace, not Logos.Env.

eval(sym, env, runtime)

@spec eval(Logos.Form.t(), Logos.Env.t(), Logos.Runtime.t()) :: term()

Evaluates form (already macroexpanded -- Logos.Eval does not call Logos.Macroexpand itself) against lexical env and runtime's namespace registry, returning the value directly. Raises Logos.EvalError on a primitive-level failure -- see moduledoc for the return convention and the tail-call structure.

resolve_symbol_location(symbol, runtime)

@spec resolve_symbol_location(Logos.Symbol.t(), Logos.Runtime.t()) ::
  {:ok, {String.t(), String.t()}} | :error

Resolves sym to a {ns, var_name} location without consulting the lexical Logos.Env chain -- this is the namespace-only half of resolution, shared by resolve_symbol/3 (which tries Env first) and Logos.Macroexpand's own private macro_lookup_by_resolution/2 (called only once that module's private macro_lookup/3 has already confirmed the head symbol isn't a shadowed lexical local -- this function itself has no locals set to check, since it's never given an Env). Still returns {:ok, _} | :error rather than raising -- unlike resolve_symbol/3, "not found" is an ordinary, expected outcome here (both callers use it as a plain lookup, not a "this must succeed" operation), not a failure to propagate.

truthy?(arg1)

@spec truthy?(term()) :: boolean()

Lisp truthiness: everything except nil and false is truthy.