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-- lexicalLogos.Envchain, then the current namespace's ownvars, then itsrefers, then (since every namespace exceptlogos.coreitself implicitly refers it)logos.core's public vars, then unbound-symbol error. - qualified
ns-or-alias/x--ns-or-aliasresolved against the current namespace'saliasesfirst, falling back to treating it as a literal full namespace name, thenxlooked up directly in that namespace's ownvarsonly -- 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.
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
@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}.
@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.
@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.
@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.
@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.
Lisp truthiness: everything except nil and false is truthy.