Lucas Sifoni

Hosting a small language (Ovo2) from scratch in Elixir, pt 5
Evaluation

elixir parsing explorations


full code hosted on github

I brought Ovo to the point to these kind of small tests pass.

test "basic evaluation 2" do
    program = fn num -> """
    sometimes_add_things = \\a -> if equals(a, 0) then
        add(add(1, a), 2)
      else
        2
      end
    end

    sometimes_add_things(#{num})
    """ end

    assert Ovo.run(program.(2)) == %Ovo.Ast{kind: :integer, nodes: [], value: 2}
    assert Ovo.run(program.(0)) == %Ovo.Ast{kind: :integer, nodes: [], value: 3}
end

This post will summarize how I chose to go from parsing to evaluation. The last step, parsing, left us with an abstract syntax tree where each node maybe bears a value, and maybe bears child nodes.

Evaluating the AST is a matter of updating an environment with user bindings as they are created, then evaluating expressions one after another, up to a final evaluation result.

def run(%Ast{} = ast, input, bindings) do
    ovo_input = Ovo.Converter.elixir_to_ovo(input)
    env = Env.make(bindings) |> Env.bind_input(ovo_input)
    {_, v} = evaluate(ast, env)
    v
end

Env.make/1 returns a basic environment, a map, split in two keys : :user and :builtins. I wanted user bindings to be able to shadow predefined bindings. bind_input pre-fills the special symbol data in the :user part of the environment, before giving this fresh environment to evaluate/2.

evaluate/2 has two heads, one handling a list and calling reduce_nodes/2:

 def evaluate(nodes, env) when is_list(nodes), do: reduce_nodes(nodes, env)

 def reduce_nodes(nodes, env) do
    Enum.reduce(nodes, {env, nil}, fn node, {ev, _lev} ->
      evaluate(node, ev)
    end)
 end

Where reduce_nodes/2 evaluates each node, returning an environment that has possibly been updated. This is how each expression in the list of expressions making up a program gets access to new bindings.

The other head of evaluate/2 is a “big old switch statement” in the sense of we only pattern match on Ast node kinds :

def evaluate(%Ovo.Ast{} = ast, env) do
    case ast.kind do
      :root ->
        evaluate(ast.nodes, env)

      :assignment ->
        key = ast.value
        {_, val} = evaluate(ast.nodes, env)
        {update_env(env, key.value, val), val}

      :block ->
        evaluate(ast.nodes, env)

      :condition ->
        [predicate, branch1, branch2] = ast.nodes
        {_, val} = evaluate(predicate, env)

        {_, v} =
          case val do
            %Ast{kind: :bool, value: true} -> evaluate(branch1, env)
            %Ast{kind: :bool, value: false} -> evaluate(branch2, env)
          end

        {env, v}
    #...

We can see that evaluate/2 returns a {env, value} tuple, and that assignments return an updated env. Symbols ultimately evaluate to a value :

 :symbol ->
        {env, find_value(ast.value, env)}

Where find_value/2 checks if the value is in the user or builtins map. Error handling and logging are still minimal, but I made the checks explicit to add messages wherever possible :^).

  def find_value(name, env) do
    if Map.has_key?(env.user, name) do
      Map.get(env.user, name)
    else
      if Map.has_key?(env.builtins, name) do
        Map.get(env.builtins, name)
      else
        {:error, "Symbol does not resolve to a value"}
      end
    end
  end

What’s maybe more interesting is how function definitions / lambdas are handled :

:lambda ->
arity = length(ast.value)
captured_env = env
program = ast.nodes

{env,
    fn args ->
    if length(args) != arity do
        {:error, "#{length(args)} argument(s) passed instead of #{arity}"}
    else
        symbols_and_args = Enum.zip(ast.value, args)

        env_with_applied_args =
        Enum.reduce(symbols_and_args, captured_env, fn {sym, arg}, uenv ->
            {_, v} = evaluate(arg, uenv)
            update_env(uenv, sym.value, v)
        end)

        {_, k} = evaluate(program, env_with_applied_args)
        k
    end
end}

A lambda evaluates to a function keeping a copy of the environment at the time it is created, and evaluating the child nodes of the lambda after having set environment keys with positional arguments. When called, this function only returns a value and no environment, since anything happening inside the lambda is private.

Function calls are then handled by finding a callable either tagged with :user or :builtins, and calling it on nodes.

:call ->
    case find_callable(ast.value.value, env) do
        {:user, fun} ->
        v = fun.(ast.nodes)
        {env, v}

        {:builtins, fun} ->
        r = fun.(ast.nodes, env)
        {env, r}

        {:error, msg} ->
        throw({:error, msg})
    end

Builtins then have the task of evaluating their arguments, type-checking them, and perform operations on them, converting them back to Ast nodes :

"add" => fn nodes, env ->
    case map_nodes(nodes, env) do
    [%Ast{kind: :integer, value: v1}, %Ast{kind: :integer, value: v2}] ->
        Ast.integer(v1 + v2)

    [%Ast{kind: :float, value: v1}, %Ast{kind: :float, value: v2}] ->
        Ast.float(v1 + v2)

    [%Ast{kind: k1, value: v1}, %Ast{kind: k2, value: v2}]
    when k1 in [:integer, :float] and k2 in [:integer, :float] ->
        Ast.float(v1 + v2)

    _ ->
        :error
    end
end,
"map" => fn nodes, env ->
    case map_nodes(nodes, env) do
    [fun, %Ast{kind: :list, nodes: items}] when is_function(fun) ->
        Enum.map(items, fn i -> fun.([i]) end)

    _ ->
        :error
    end
end,

The map_nodes/2 function also takes adantage of evaluate/2 to resolve values from arguments.

defp map_nodes(nodes, env) do
    Enum.map(nodes, fn node ->
        {_, v} = Ovo.Interpreter.evaluate(node, env)
        v
    end)
end

All of this, stiched together (see full code hosted on github) for reference, allows me to evaluate basic programs like the one shown on top of this page :

sometimes_add_things = \\a -> if equals(a, 0) then
        add(add(1, a), 2)
    else
        2
    end
end

sometimes_add_things(0)

Evaluating to %Ovo.Ast{kind: :integer, nodes: [], value: 3}.

The next task is the implementation of a wide enough “standard library” before going for the visual editor. I encourage any reader to give a try to the construction of a small interpreted language in a high-level language :^) .


Previous post : Heavy modifications to the 200mm f/3.52 dob's upper cage.
Next post : Hosting a small language (Ovo2) from scratch in Elixir, pt 6
Basic recursion : an environment is a process