Skip to content

Commit

Permalink
Add erlang-module support
Browse files Browse the repository at this point in the history
Single modules are now allowed to be defined in an Erlang-cell.

In this case the entire code-block is interpreted as an
erlang-module and if there are no errors the module is compiled
and loaded.

If the cell containing the module is deleted subsequent code
invocations will fail.

Stale indicator is not working yet due to missing notion of
functions, this will be fixed in a next version.

Error handling - basics are working
 - Still not happy with it, but it is usable
  • Loading branch information
fnchooft committed Oct 1, 2024
1 parent b51a6ed commit c8b9e4f
Show file tree
Hide file tree
Showing 2 changed files with 185 additions and 10 deletions.
127 changes: 117 additions & 10 deletions lib/livebook/runtime/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,9 @@ defmodule Livebook.Runtime.Evaluator do
end

start_time = System.monotonic_time()

{eval_result, code_markers} = eval(language, code, context.binding, context.env)

evaluation_time_ms = time_diff_ms(start_time)

%{tracer_info: tracer_info} = Evaluator.IOProxy.after_evaluation(state.io_proxy)
Expand Down Expand Up @@ -689,19 +691,127 @@ defmodule Livebook.Runtime.Evaluator do
end

defp eval(:erlang, code, binding, env) do
case :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]) do
{:ok, tokens, _} ->
case find_first_module_attribute(tokens) do
{:ok, _module_name} ->
eval_erlang_module(code, tokens, binding, env)

:error ->
eval_erlang_statements(code, tokens, binding, env)
end

# Tokenizer error
{:error, {location, module, description}, _end_loc} ->
process_erlang_error(env, code, location, module, description)
end
end

# ------------------------------------------------------------------------
# Simple Erlang Module - helper functions
# ------------------------------------------------------------------------
# Start of an attribute
defp is_not_module_declaration({:-, _}), do: false
# Ignore comments
defp is_not_module_declaration({_, _, :comment, _}), do: true
# Ignore everything else
defp is_not_module_declaration(_), do: true

# Find the first valid module declaration in a token list
defp find_first_module_attribute(tokens) do
case :lists.dropwhile(&is_not_module_declaration/1, tokens) do
# Multiple declarations of a module could be defined, that's fine, we need at least one to run in module mode
[
{:-, _},
{:atom, _, :module},
{:"(", _},
{:atom, _, module_name},
{:")", _},
{:dot, _}
| _
] ->
{:ok, module_name}

_ ->
# No -module(module_name). attribute
:error
end
end

defp tokens_to_forms([], acc) do
{:ok, :lists.reverse(acc)}
end

defp tokens_to_forms(tokens, acc) do
{form, rest} = until_dot(tokens, [])

case :erl_parse.parse_form(form) do
{:ok, form} -> tokens_to_forms(rest, [form | acc])
{:error, reason} -> {:error, reason}
end
end

defp until_dot([{:dot, _} = head | tail], acc),
do: {Enum.reverse([head | acc]), tail}

defp until_dot([head | tail], acc),
do: until_dot(tail, [head | acc])

defp until_dot([], acc),
do: {Enum.reverse(acc), []}

# Create module - tokens from string
# Based on: https://stackoverflow.com/questions/2160660/how-to-compile-erlang-code-loaded-into-a-string
# Step 1: Scan the code
# Step 2: Convert to forms
# Step 3: Extract module name
# Step 4: Compile and load
# Step 5: If compile success - register module

defp eval_erlang_module(_code, tokens, binding, env) do
try do
{:ok, forms} = tokens_to_forms(tokens, [])

case :compile.forms(forms) do
{:ok, _, binary_module} ->
# First statement - form = module definition
{:attribute, _, :module, module} = hd(forms)
# Line below should be done in Tracer...
{:module, module} =
:code.load_binary(module, ~c"#{module}.beam", binary_module)

# Registration of module
env = %{env | module: module, versioned_vars: %{}}
Evaluator.Tracer.trace({:on_module, binary_module, %{}}, env)
result = {:ok, module}
{{:ok, result, binding, env}, []}

:error ->
error = "compile.forms failed - syntax error"
{{:error, :error, error, []}, []}
# process_erlang_error(env, code, {1,1}, :compile,"Compile forms failed")
end
catch
kind, error ->
stacktrace = prune_stacktrace(:erl_eval, __STACKTRACE__)
{{:error, kind, error, stacktrace}, []}
end
end

defp eval_erlang_statements(code, tokens, binding, env) do
try do
erl_binding =
Enum.reduce(binding, %{}, fn {name, value}, erl_binding ->
:erl_eval.add_binding(elixir_to_erlang_var(name), value, erl_binding)
end)

with {:ok, tokens, _} <- :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]),
{:ok, parsed} <- :erl_parse.parse_exprs(tokens),
with {:ok, parsed} <- :erl_parse.parse_exprs(tokens),
{:value, result, new_erl_binding} <- :erl_eval.exprs(parsed, erl_binding) do
# Simple heuristic to detect the used variables. We look at
# the tokens and assume all var tokens are used variables.
# This will not handle shadowing of variables in fun definitions
# and will only work well enough for expressions, not for modules.

used_vars =
for {:var, _anno, name} <- tokens,
do: {erlang_to_elixir_var(name), nil},
Expand Down Expand Up @@ -731,10 +841,6 @@ defmodule Livebook.Runtime.Evaluator do

{{:ok, result, binding, env}, []}
else
# Tokenizer error
{:error, {location, module, description}, _end_loc} ->
process_erlang_error(env, code, location, module, description)

# Parser error
{:error, {location, module, description}} ->
process_erlang_error(env, code, location, module, description)
Expand Down Expand Up @@ -873,11 +979,12 @@ defmodule Livebook.Runtime.Evaluator do
do: {:module, module},
into: identifiers_used

# Note: `module_info` works for both Erlang and Elixir modules, as opposed to `__info__`
# TODO: Find out why we need the case -
identifiers_defined =
for {module, _line_vars} <- tracer_info.modules_defined,
version = module.__info__(:md5),
do: {{:module, module}, version},
into: identifiers_defined
for {module, _line_vars} <- tracer_info.modules_defined, into: identifiers_defined do
{{:module, module}, module.module_info(:md5)}
end

# Aliases

Expand Down
68 changes: 68 additions & 0 deletions test/livebook/runtime/evaluator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,74 @@ defmodule Livebook.Runtime.EvaluatorTest do
assert_receive {:runtime_evaluation_response, :code_1, terminal_text("6"), metadata()}
end

test "evaluate erlang-module code", %{evaluator: evaluator} do
Evaluator.evaluate_code(
evaluator,
:erlang,
"-module(tryme). -export([go/0]). go() ->{ok,went}.",
:code_4,
[]
)

assert_receive {:runtime_evaluation_response, :code_4, terminal_text(_), metadata()}
end

test "evaluate erlang-module error function already defined", %{evaluator: evaluator} do
Evaluator.evaluate_code(
evaluator,
:erlang,
"-module(tryme). -export([go/0]). go() ->{ok,went}. go() ->{ok,went}.",
:code_4,
[]
)

assert_receive {
:runtime_evaluation_response,
:code_4,
error(message),
metadata()
}
assert message =~ "compile.forms failed - syntax error"
end

test "evaluate erlang-module error - expression after module", %{evaluator: evaluator} do
Evaluator.evaluate_code(
evaluator,
:erlang,
"-module(tryme). -export([go/0]). go() ->{ok,went}. go() ->{ok,went}. A = 1.",
:code_4,
[]
)

assert_receive {
:runtime_evaluation_response,
:code_4,
error(message),
metadata()
}

assert message =~ "\"syntax error before: \",\"A\""
end

test "evaluate erlang-module error - two modules", %{evaluator: evaluator} do
Evaluator.evaluate_code(
evaluator,
:erlang,
"-module(one). -export([go/0]). go() ->{ok,one}. -module(two). -export([go/0]). go() ->{ok,two}.",
:code_4,
[]
)

assert_receive {
:runtime_evaluation_response,
:code_4,
error(message),
metadata()
}

assert message =~ "compile.forms failed - syntax error"
end

test "mixed erlang/elixir bindings", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, :elixir, "x = 1", :code_1, [])
Evaluator.evaluate_code(evaluator, :erlang, "Y = X.", :code_2, [:code_1])
Expand Down

0 comments on commit c8b9e4f

Please sign in to comment.