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

balanced?(text)

@spec balanced?(String.t()) :: boolean()

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.

eval_and_print(source, runtime, ns_name)

@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, if source called in-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.

remote_eval(runtime_name, source, ns_name)

@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.

start(opts \\ [])

@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.