Tutorial: embedding Logos in an Elixir application

Copy Markdown

This tutorial is about using Logos as a library from Elixir host code -- creating runtimes, evaluating source text, calling back and forth between Logos and Elixir. If you want to learn the Logos language itself (the Lisp dialect: special forms, macros, syntax-quote, concurrency), see the language tutorial instead. This one assumes no Logos language knowledge beyond (+ 1 2).

1. Add the dependency

# mix.exs
def deps do
  [
    {:logos, path: "path/to/logos"}
  ]
end

Then mix deps.get.

2. Create a Runtime

A Logos.Runtime is Logos's unit of isolation: a fresh, per-instance, ETS-backed namespace/Var registry. Nothing about it is a global singleton -- an application embedding Logos twice (say, one runtime per tenant) gets two completely independent worlds, with independent defs, independent namespaces, independent everything.

runtime = Logos.new_runtime()

Logos.new_runtime/1 is a convenience: it builds a bare Logos.Runtime and loads the stdlib (priv/stdlib/*.logos -- logos.core, logos.seq, logos.concurrency, logos.multimethod, plus the primitives-only logos.map, all referred into logos.core) into it (so if/let/ when/defn/map/filter/get/receive/defmulti/etc. are ready to use, unqualified, from anywhere). logos.test (deftest/assert/ run-tests) also loads, but deliberately isn't referred into logos.core -- testing macros have no business being unqualified in every embedding's production namespaces, so it's only reachable once a script explicitly (require '[logos.test :refer [:all]])s it. If you ever need the primitives without the stdlib layer (rare -- mostly useful for isolating a stdlib bug), you can call Logos.Runtime.new/1 directly and skip Logos.Stdlib.load!/1.

3. Evaluate a string

{:ok, value, env} = Logos.eval_string("(+ 1 2 3)", runtime)
value
#=> 6

Logos.eval_string/3 reads exactly the first top-level form out of the given source, macroexpands it, and evaluates it. It returns a 3-tuple: the value, and an env (a Logos.Env) you can thread into a later call for lexical continuity -- useful if you want top-level let-like behavior across multiple eval_string calls (a REPL, for instance). Ordinary def visibility, though, comes from runtime, not env -- once you def something, it's visible in every later call against the same runtime regardless of what env you pass.

{:ok, _value, _env} = Logos.eval_string("(def x 42)", runtime)
{:ok, value, _env} = Logos.eval_string("x", runtime)
value
#=> 42

4. Evaluate a whole script (many forms)

Most of the time you have more than one top-level form -- a whole file, or a multi-statement snippet. Logos.eval_string_sequence/3 reads every top-level form and evaluates them in order, threading env from one form to the next, returning the last value:

source = """
(defn square [x] (* x x))
(defn sum-of-squares [a b] (+ (square a) (square b)))
(sum-of-squares 3 4)
"""

{:ok, value, _env} = Logos.eval_string_sequence(source, runtime)
value
#=> 25

Errors halt the sequence: if an earlier form errors, later forms are never evaluated, and eval_string_sequence/3 returns that first {:error, _}.

5. Defining functions and calling them from Elixir

Every def/defn interns a Var into the runtime's current namespace ("user" by default). You can read a Var's value straight out of the runtime without going through eval_string again:

{:ok, _, _} = Logos.eval_string("(defn double [x] (* x 2))", runtime)
{:ok, fn_value} = Logos.Namespace.get_var_value(runtime, "user", "double")
Logos.Eval.apply_fn(fn_value, [21], runtime)
#=> {:ok, 42}

Logos.Eval.apply_fn/3 is the same function the evaluator itself uses to apply a callable to already-evaluated Elixir-side arguments -- there's no separate "host calling convention" to learn.

6. Errors

Every evaluation entry point returns {:ok, value, env} or {:error, reason} -- Logos-level failures (an unbound symbol, a wrong arity, a type mismatch in a primitive) never raise an Elixir exception into your host code. An uncaught Logos-level (throw ...) with no enclosing try is likewise converted into {:error, {:uncaught_throw, tag, value}} at this boundary, so a host application only ever has one error shape to handle:

Logos.eval_string("(+ 1 :not-a-number)", runtime)
#=> {:error, {:not_a_number, [1, :not-a-number]}}

Logos.eval_string("(throw :validation-error \"bad input\")", runtime)
#=> {:error, {:uncaught_throw, :validation-error, "bad input"}}

(Logos.Keyword implements Elixir's Inspect protocol to print itself as :name rather than the raw struct, which is why a %Logos.Keyword{} shows up looking like a plain Elixir atom above -- it isn't one; compare == against another Logos.Keyword.intern/1 value, not an Elixir atom.)

7. Printing values back to text

Logos.Printer.print/1 turns any Logos value back into readable text -- useful for showing a result to a user, or logging it. Every reader-producible type round-trips (you could feed the printed text back into Logos.Reader.read/1 and get an ==-equal value); the three runtime-only types (Logos.Fn, Logos.Atom, Logos.Pid) print in an informative #<...> form instead, since there's no source syntax for a live closure or process handle.

{:ok, value, _} = Logos.eval_string("[1 2 {:a 3}]", runtime)
Logos.Printer.print(value)
#=> "[1 2 {:a 3}]"

8. A worked example: a scripted rules engine

A realistic reason to embed Logos: letting a host application load small, user-editable scripts without recompiling. Here's a minimal version -- a "rule" is a Logos function taking plain positional arguments and returning a keyword verdict, and the host runs a whole file of them:

Note: Logos maps support get/assoc/dissoc and Clojure-style (:key map) keyword-as-function lookup, so a rule could just as well take a single map argument and pull fields out of it with (:total order). The example below sticks to plain positional arguments for simplicity -- see the cheatsheet and CONTRIBUTING.md for the honest list of current gaps.

defmodule MyApp.RuleEngine do
  @rules_source """
  (defn flag-large-order [total]
    (if (> total 1000) :review :ok))

  (defn flag-new-customer [new-customer?]
    (if new-customer? :review :ok))
  """

  def new do
    runtime = Logos.new_runtime()
    {:ok, _value, _env} = Logos.eval_string_sequence(@rules_source, runtime)
    runtime
  end

  def run_rule(runtime, rule_name, args) do
    {:ok, rule_fn} = Logos.Namespace.get_var_value(runtime, "user", rule_name)
    Logos.Eval.apply_fn(rule_fn, args, runtime)
  end
end
runtime = MyApp.RuleEngine.new()
MyApp.RuleEngine.run_rule(runtime, "flag-large-order", [1500])
#=> {:ok, :review}
MyApp.RuleEngine.run_rule(runtime, "flag-new-customer", [false])
#=> {:ok, :ok}

From here: the library cheatsheet has a quick-reference for the calls used above (spawning a process, registering an import allowlist entry, running a file), and the language tutorial covers everything you can put in @rules_source itself.