Advanced Topics

View Source

This guide covers lower-level APIs for applications that need direct access to the parse tree, expression inversion, or custom functions.

The parse → validate → evaluate pipeline

Most callers use Elex.evaluate/2, which runs all three stages. For more control, use the modules directly:

context = Elex.new_context() |> Elex.add_variable("x", 10)

{:ok, ast, type} = Elex.Parser.parse("x + 5", context)
# type => :decimal

Elex.Evaluator.evaluate(ast, context)
# => #Decimal<15>

Elex.Parser.parse/3 validates by default. Pass validate: false to parse without checking variable types — useful for extracting structure before a context is fully built:

{:ok, ast, nil} = Elex.Parser.parse("x + y", context, validate: false)
Elex.Validator.validate(ast, context)
# => {:ok, :decimal} or {:error, reason}

Note: Elex.Evaluator.evaluate/2 raises RuntimeError on failures. Elex.evaluate/2 catches these and returns {:error, reason}. Prefer the high-level API unless you handle exceptions yourself.

AST format

The parser produces plain Erlang terms (tagged tuples). Common shapes:

FormMeaning
%Decimal{}Decimal literal
true, falseBoolean literal
"string"String literal
nilNull literal
{:var, "name"}Variable reference
{op, [left, right]}Binary operator (:+, :-, :*, :/, :%, comparisons, and, or)
{:not, operand}Logical not
{-, operand}Unary minus
{:func, name, arity, args}Function call (name is a string, args is a list of AST nodes)

Example AST for max(x, 10) + 1:

{:+, [
  {:func, "max", 2, [{:var, "x"}, #Decimal<10>]},
  #Decimal<1>
]}

Parser debugging

Elex.Parser.debug/2 exposes raw NimbleParsec output for troubleshooting. It does not validate against a context:

info = Elex.Parser.debug("( 1 + 2")
info.status   # :error
info.rest     # "( 1 + 2"
info.reason   # raw parser message

The result is a map documented as debug_info/0 on Elex.Parser. For end-user error messages, use Elex.Parser.parse/3 or Elex.evaluate/2 instead.

Expression inversion

Elex.Inverter solves simple single-variable arithmetic expressions. Given an AST and a target variable name, it returns an inverted AST representing the inverse operation.

Supported operations: +, -, *, / (with the variable on one side only).

alias Elex.{Parser, Inverter}

context = Elex.new_context() |> Elex.add_variable("value", 0)

{:ok, ast, _} = Parser.parse("value * 2 + 5", context, validate: false)
{:ok, inverted} = Inverter.invert(ast, "value")

# Evaluate inverted AST with a known result to recover the original variable:
result_context = Elex.new_context() |> Elex.add_variable("value", 21)
Elex.Evaluator.evaluate(inverted, result_context)
# => #Decimal<8>  (because (8 * 2) + 5 = 21)

Inverter.invert/2 returns {:error, reason} when:

  • The expression contains more than one variable
  • The target variable is not present
  • The expression uses unsupported operations (comparisons, functions, etc.)
  • Division by zero would occur during inversion

Custom functions

Implement the Elex.Function behaviour with four callbacks:

CallbackPurpose
signature/0Function name and arity (or variadic spec)
validate/2Type-check unevaluated argument ASTs at parse time
call/1Execute with evaluated argument values
documentation/0Human-readable signature and description

Example

defmodule MyApp.Functions.Double do
  @behaviour Elex.Function

  @impl true
  def signature, do: %{name: :double, arity: 1}

  @impl true
  def validate([arg], ctx) do
    case Elex.Validator.validate(arg, ctx) do
      {:ok, :decimal} -> {:ok, :decimal}
      {:ok, type} -> {:error, "double expects a number, got #{inspect(type)}"}
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def call([arg]), do: {:ok, Decimal.mult(arg, Decimal.new(2))}

  @impl true
  def documentation do
    %{signature: "double(x)", description: "returns x multiplied by 2"}
  end
end

Register the function on a context:

context =
  Elex.new_context()
  |> Elex.Context.add_function(MyApp.Functions.Double)

{:ok, result} = Elex.evaluate("double(5)", context)
# => #Decimal<10>

Variadic functions

Return a variadic signature with min_arity:

def signature do
  %{name: :sum, variadic: true, min_arity: 2}
end

The context stores variadic functions under {name, :variadic}.

Localizing type error labels

Elex.Labels.label/1 maps type atoms to human-readable names used in error messages (:decimal"number", etc.). Override this function in your application to integrate with Gettext for localization.

Further reading