Logos. Repl
(Logos v0.2.0)
Copy Markdown
The local REPL (mix logos.repl): a fresh Runtime with logos.core
and project files preloaded, a stdin read-eval-print loop, and a
Clojure-style prompt (user=>). History vars *1/*2/*3/*e are
ordinary def'd Vars. Each entered form is evaluated in its own
spawned+monitored process, which gives real crash isolation and (in
principle) a way to cancel just that form -- see the Ctrl+C section
below for exactly what's implemented today.
The namespace-continuity design decision (read this before touching
eval_top_level/3)
Logos.Runtime's :current_ns is process-scoped ({:current_ns, self()}, see that module's moduledoc) -- a real fix for concurrent
spawned processes not stomping on each other's (in-ns ...). The
flip side: if every REPL-entered form ran in a fresh spawned
process, each fresh process would fall back to the Runtime's shared
default :current_ns row every time, silently ignoring whatever
namespace a previous form's (in-ns ...) call left the REPL in --
(in-ns 'foo) typed at the prompt would never actually stick, making
the REPL unusable for anything but the user namespace.
This module tracks the REPL's "current namespace" as a plain Elixir
value (ns_name, threaded through the read-eval-print loop, entirely
outside Logos.Runtime's own per-process ETS rows), and every freshly
spawned per-form worker process explicitly calls Logos.Runtime.set_current_ns!/2
with that tracked value as its first action, before reading or
evaluating the user's form at all (reading matters too, not just eval --
syntax-quote auto-qualification, lib/logos/reader/actions.ex, consults
Logos.Runtime.current_ns/1, which is itself process-scoped). After the
form finishes, the worker reads back Logos.Runtime.current_ns/1 (in
case the form itself called in-ns) and reports it back to the loop,
which becomes the tracked ns_name for the next form's worker. This
keeps the "spawn a fresh, monitored process per form so a stuck/crashed
form can't take down the REPL" property (real crash isolation, real
:DOWN messages) while still making (in-ns ...) behave exactly the
way a REPL user expects. The alternative -- one long-lived worker
process reused across every form -- was deliberately rejected: it would
lose the "a wedged/infinite-looping form doesn't wedge the whole REPL"
property entirely, which is the actual point of spawning per form in
the first place.
Testable core vs. interactive shell
eval_and_print/3 is the pure, testable core: given a Runtime and a
form string, it produces the printed result and updated history vars
with no stdin/stdout I/O at all, so test/logos/repl_test.exs
exercises it directly. start/1 is the thin interactive stdin loop
shell built on top of it (client-side paren-balance
continuation-prompt detection, printing, IO.gets/1).
Ctrl+C -- best-effort, and here's exactly what that means
Ctrl+C support is intentionally best-effort, and documented as such.
System.trap_signal/3 (Elixir 1.12+) was the obvious candidate
mechanism to kill only the currently-running per-form worker process
on Ctrl+C rather than the whole REPL -- it cannot be used for this.
System.trap_signal/3's own guard clause only accepts :sigquit,
:sigterm, :sigusr1, :sighup, :sigabrt, :sigalrm, :sigusr2,
:sigchld, :sigstop, :sigtstp -- :sigint (Ctrl+C) is deliberately
not in that list, because the BEAM already gives SIGINT special
meaning at the VM level (the familiar "BREAK" menu / iex's own
Ctrl+C-twice-to-quit prompt) that isn't exposed as an overridable Elixir
callback. Given that, this module makes no attempt to intercept SIGINT
itself -- Ctrl+C during mix logos.repl gets the BEAM's own default
BREAK-menu behavior (informative, but not a surgical "kill only this
form"). What this module genuinely does deliver toward that goal, and
does verifiably (see test/logos/repl_test.exs): every form still runs
in its own freshly spawn_monitored process, so a form that
raises/crashes/times out never brings down the REPL loop itself, and
this module's current_worker/0 (an internal, undocumented function --
see its own @doc false) exposes the pid of whatever form is
currently running so a different supervising mechanism
(host-app-specific, out of scope here) could Process.exit/2 it on
some other trigger. Flagged honestly rather than silently shipping a
SIGINT handler that would never actually fire.
Summary
Functions
Whether text is a syntactically-complete (balanced) sequence of
top-level forms. This is deliberately its own client-side
paren/bracket/brace balance counter (ignoring parens inside
strings/comments) rather than relying on Ichor's own incomplete-input
detection. Delegates to Logos.Reader.tokenize/1 (the same raw token stream
Logos.Format is built on) for the "ignoring parens inside
strings/comments" part for free: a STRING/CHAR token's text already
fully consumed any (/) characters that happened to appear inside a
string/char literal at the lexer level (they never become their own
LPAREN/RPAREN tokens), and a comment is folded into a single
TRIVIA token the same way. An empty string, or text the tokenizer
itself rejects (e.g. a still-open string literal, mid-typing), counts
as not yet balanced -- keep reading.
Given runtime, an already-complete source form, and the REPL's
currently-tracked namespace name ns_name, evaluates it (per the
namespace-continuity decision above -- via a fresh spawned+monitored
worker process that first sets its own current namespace to ns_name),
updates the history Vars (*1/*2/*3 on success, *e on error),
and returns a plain map with the printed result and the
namespace to use for the next form
Mix.Tasks.Logos.Remsh's RPC entry point: each form entered at a
mix logos.remsh prompt is shipped to the target node and evaluated
there -- this session never reads the target's ETS tables directly.
Called as :rpc.call(target_node, Logos.Repl, :remote_eval, [runtime_name, source, ns_name]) -- runs entirely on the target node (that's what
:rpc.call/4 does), so Logos.Runtime.Registry.lookup/1 resolves
runtime_name against the target node's own local registry, handing
eval_and_print/3 a %Logos.Runtime{} whose ETS table id is genuinely
valid there (an :ets.tid() from one node is meaningless on another --
never shipped across the wire in either direction; only runtime_name,
a plain string, crosses over in the request, and only the printed
result map crosses back).
Starts the interactive read-eval-print loop against runtime (a fresh
Logos.new_runtime/0 if omitted), reading from stdin until EOF
(Ctrl+D). Prints a Clojure-style prompt (<ns>=>), a continuation
prompt (..., no namespace prefix -- mirrors Clojure's own
#_=>-style secondary prompt in spirit, kept simple here) while a form
is unbalanced, and evaluates each complete form via eval_and_print/3.
Functions
Whether text is a syntactically-complete (balanced) sequence of
top-level forms. This is deliberately its own client-side
paren/bracket/brace balance counter (ignoring parens inside
strings/comments) rather than relying on Ichor's own incomplete-input
detection. Delegates to Logos.Reader.tokenize/1 (the same raw token stream
Logos.Format is built on) for the "ignoring parens inside
strings/comments" part for free: a STRING/CHAR token's text already
fully consumed any (/) characters that happened to appear inside a
string/char literal at the lexer level (they never become their own
LPAREN/RPAREN tokens), and a comment is folded into a single
TRIVIA token the same way. An empty string, or text the tokenizer
itself rejects (e.g. a still-open string literal, mid-typing), counts
as not yet balanced -- keep reading.
@spec eval_and_print(String.t(), Logos.Runtime.t(), String.t()) :: %{ printed: String.t(), ns: String.t(), ok?: boolean(), value: term() }
Given runtime, an already-complete source form, and the REPL's
currently-tracked namespace name ns_name, evaluates it (per the
namespace-continuity decision above -- via a fresh spawned+monitored
worker process that first sets its own current namespace to ns_name),
updates the history Vars (*1/*2/*3 on success, *e on error),
and returns a plain map with the printed result and the
namespace to use for the next form:
:printed-- the text the REPL should display.:ns-- the (possibly-updated, ifsourcecalledin-ns) namespace name to track for the next call.:ok?-- whether evaluation succeeded.:value-- the raw result value (the evaluated value on success, the error reason on failure) -- exposed for callers (e.g. tests) that want to assert on it directly rather than parse:printed.
Pure with respect to the caller (no I/O) -- runtime's ETS table is,
as always, mutated (Vars interned/updated), matching every other
Logos-level "evaluation" entry point's own conventions.
@spec remote_eval(String.t(), String.t(), String.t()) :: %{ printed: String.t(), ns: String.t(), ok?: boolean() }
Mix.Tasks.Logos.Remsh's RPC entry point: each form entered at a
mix logos.remsh prompt is shipped to the target node and evaluated
there -- this session never reads the target's ETS tables directly.
Called as :rpc.call(target_node, Logos.Repl, :remote_eval, [runtime_name, source, ns_name]) -- runs entirely on the target node (that's what
:rpc.call/4 does), so Logos.Runtime.Registry.lookup/1 resolves
runtime_name against the target node's own local registry, handing
eval_and_print/3 a %Logos.Runtime{} whose ETS table id is genuinely
valid there (an :ets.tid() from one node is meaningless on another --
never shipped across the wire in either direction; only runtime_name,
a plain string, crosses over in the request, and only the printed
result map crosses back).
Deliberately returns a smaller map than eval_and_print/3 --
:printed/:ns/:ok? only, dropping :value -- rather than trying to
ship the raw evaluated Logos.Value.t() back over :rpc.call's own
term-serialization: an arbitrary Logos value can be a %Logos.Fn{}
closure carrying a captured Logos.Env (itself potentially holding
local pids from the target node, meaningless as data on the caller
node) or a %Logos.Pid{}/%Logos.Atom{} wrapping a target-node-local
pid. Real cross-node distribution in Logos always goes through this
:rpc.call per top-level form -- a Logos closure or process reference
is never passed directly as a raw message between nodes. The printed
text is always safe to ship; the raw value is not, in general -- so
this entry point never tries.
@spec start(keyword()) :: :ok
Starts the interactive read-eval-print loop against runtime (a fresh
Logos.new_runtime/0 if omitted), reading from stdin until EOF
(Ctrl+D). Prints a Clojure-style prompt (<ns>=>), a continuation
prompt (..., no namespace prefix -- mirrors Clojure's own
#_=>-style secondary prompt in spirit, kept simple here) while a form
is unbalanced, and evaluates each complete form via eval_and_print/3.