Examples: embedding Logos
Copy MarkdownComplete, runnable examples of using Logos as a library from Elixir. For examples of Logos programs (the language itself), see LOGOS_EXAMPLES.md.
Example 1: one-off evaluation
The simplest possible embedding -- evaluate a single expression and get a value back.
runtime = Logos.new_runtime()
{:ok, value, _env} = Logos.eval_string("(* 6 7)", runtime)
value
#=> 42(Verified against the real implementation.)
Example 2: running a whole script file
In plain English: this wraps the two-step "read a file, then evaluate
its contents" pattern in a small Elixir module. run_file/1 reads
path off disk with ordinary File.read/1, and if that succeeds, hands
the whole file's text to Logos.eval_string_sequence/3 -- unlike
Logos.eval_string/3 (Example 1), which reads and evaluates only the
first top-level form, eval_string_sequence/3 reads and evaluates
every top-level form in the file, in order, and returns the value of
the last one. with here is ordinary Elixir control flow, not anything
Logos-specific: if File.read/1 fails, its own {:error, reason} is
returned as-is and eval_string_sequence/3 is never called.
defmodule MyApp.ScriptRunner do
def run_file(path) do
runtime = Logos.new_runtime()
with {:ok, source} <- File.read(path) do
Logos.eval_string_sequence(source, runtime)
end
end
endThe script below defines a function shout (uppercase a string) and
then immediately calls it -- eval_string_sequence/3's return value is
the result of that last call, (shout "hello from a file"), not the
defn above it.
File.write!("/tmp/greeting.logos", """
(defn shout [s] (upcase s))
(shout "hello from a file")
""")
# `upcase` isn't a Logos primitive -- see Example 4 for how a host
# application registers one before running a script that expects it.
# With `upcase` registered as shown there:
MyApp.ScriptRunner.run_file("/tmp/greeting.logos")
#=> {:ok, "HELLO FROM A FILE", _env}Example 3: reading a Var back out after evaluation
Every def/defn interns directly into the runtime; you don't have to
re-evaluate Logos source to get the value back out.
runtime = Logos.new_runtime()
{:ok, _, _} = Logos.eval_string("(def pi 3.14159)", runtime)
{:ok, value} = Logos.Namespace.get_var_value(runtime, "user", "pi")
value
#=> 3.14159
# Metadata (docstrings, ^:private) lives alongside the value:
Logos.Var.meta(runtime, Logos.Var.new("user", "pi"))
#=> %{}(Verified against the real implementation.)
Example 4: exposing an Elixir function to Logos code
import (the Logos-side primitive) only reaches functions listed in
Logos.Interop.Allowlist -- but any allowlisted key, dotted or not, can
now be named directly from Logos source, since SYMBOL_CHAR includes
.:
runtime = Logos.new_runtime()
{:ok, value, _env} =
Logos.eval_string_sequence(
"""
(import 'String.upcase)
(upcase "hello from logos")
""",
runtime
)
value
#=> "HELLO FROM LOGOS"(Verified against the real implementation.)
For a function that isn't on the allowlist (and you'd rather not extend
Logos.Interop.Allowlist for a one-off), the host-Elixir-side path still
works too -- intern it directly, bypassing import entirely:
runtime = Logos.new_runtime()
# {:host_fn, mod, fun} is the same marker `import` itself would produce --
# Logos.Eval.apply_fn/3 dispatches `Kernel.apply(mod, fun, args)` for it.
Logos.Namespace.intern!(runtime, "user", "upcase", {:host_fn, String, :upcase})
{:ok, value, _env} = Logos.eval_string(~s|(upcase "hello from logos")|, runtime)
value
#=> "HELLO FROM LOGOS"(Verified against the real implementation.)
Example 5: spawning a Logos-defined worker process
spawn starts a genuine BEAM process running a Logos closure -- ordinary
Process/:observer tooling on the Elixir side sees it like any other
process, because it is one.
runtime = Logos.new_runtime()
source = """
(defn worker []
(receive [msg]
((= msg :ping) (send (self) :pong-noted))))
"""
{:ok, _, _} = Logos.eval_string_sequence(source, runtime)
{:ok, worker_fn} = Logos.Namespace.get_var_value(runtime, "user", "worker")
pid = Logos.Process.spawn(worker_fn, runtime)
Logos.Process.send(pid, :ping)
Process.alive?(pid.pid)
#=> true (briefly -- the worker replies to itself and its `receive` returns, so it exits normally soon after)(Verified against the real implementation -- spawn/send return real
%Logos.Pid{} structs wrapping genuine BEAM pids.)
Example 6: catching a Logos-level error at the host boundary
In plain English: (+ 1 :not-a-number) tries to add a number and a
keyword, which Logos rejects -- but it rejects it the same way every
other Logos-level failure is reported, as an {:error, reason} return
value from eval_string/3, not as a raised Elixir exception. So an
ordinary case (no try/rescue) is enough to tell success and
failure apart and react to each.
runtime = Logos.new_runtime()
case Logos.eval_string("(+ 1 :not-a-number)", runtime) do
{:ok, value, _env} ->
{:ok, value}
{:error, reason} ->
Logger.warning("script failed: #{inspect(reason)}")
{:error, :script_failed}
endNo try/rescue needed on the Elixir side for ordinary Logos-level
failures (bad arithmetic, unbound symbols, uncaught throw) -- they all
surface as {:error, reason}. An Elixir-level exception from
Logos.eval_string/3 itself would mean something outside Logos's own
error-handling went wrong (e.g. a bug in a host function called through
import/{:host_fn, ...}), which is worth actually crashing loudly for
rather than silently swallowing.
Example 7: multiple independent runtimes (multi-tenant isolation)
tenant_a = Logos.new_runtime()
tenant_b = Logos.new_runtime()
Logos.eval_string("(def secret 1)", tenant_a)
Logos.eval_string("secret", tenant_b)
#=> {:error, {:unbound_symbol, "secret"}}secret defined in tenant_a's runtime is completely invisible from
tenant_b's -- each Logos.Runtime.new/1 call is its own ETS table, so
there's no data leak between them by construction, not by convention.
(Verified against the real implementation.)