Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add erlang-module support #2094

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions lib/livebook/runtime/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,109 @@ defmodule Livebook.Runtime.Evaluator do
{result, code_markers}
end

# main entry point to decide between erlang-statements vs module
defp eval(:erlang, code, binding, env) do
is_module = String.starts_with?(code, "-module(")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of detecting based on a string, we can do the first pass with :erl_scan.string(String.to_charlist(str)) and then traverse all entries and see if any defines a module attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your opinion: Analyze the forms to only allow one module? Throw an error to instruct the user?
Seems more inline with how Erlang defines modules.

If you defined multiple modules we would have to have a lot more checks, and we would have to seperate the statements per module.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, for now, I think the best is to:

  1. Check if it has attributes
  2. If it has attributes, one of them (no more, no less) has to be a module attribute


case is_module do
true -> eval_module(:erlang, code, binding, env)
false -> eval_statements(:erlang, code, binding, env)
end
end

# Simple Erlang Module - helper functions
# ------------------------------------------------------------------------
# In order to handle the expression in their forms - separate per {:dot,_}
defp not_dot({:dot, _}) do
false
end

defp not_dot(_) do
true
end

# A list of scanned token - must be seperated per dot, in order to feed them
# into the :erl_parse.parse_form function.
defp tokens_to_forms(tokens) do
{:ok, tokens_to_forms(tokens, [])}
end

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

defp tokens_to_forms(tokens, acc) do
form = :lists.takewhile(&not_dot/1, tokens)
[dot | rest] = :lists.dropwhile(&not_dot/1, tokens)
tokens_to_forms(rest, [{form ++ [dot]}] ++ acc)
end

defp parse_forms(form_statements) do
try do
res =
Enum.map(
form_statements,
fn {form_statement} ->
case :erl_parse.parse_form(form_statement) do
{:ok, form} -> form
err -> throw({:parse_fail, err})
end
end
)

{:ok, res}
catch
{:parse_fail, err} -> err
end
end

# Create module - tokens from string
# Based on: https://stackoverflow.com/questions/2160660/how-to-compile-erlang-code-loaded-into-a-string
# The function will first assume that code starting with -module( is a erlang module definition

# 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 - calculate md5 and register module

defp eval_module(:erlang, code, binding, env) do
try do
with {:ok, tokens, _} <- :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]),
{:ok, form_statements} <- tokens_to_forms(tokens),
{:ok, forms} <- parse_forms(form_statements) do
# First statement - form = module definition
{:attribute, _, :module, module_name} = hd(forms)

# Compile the forms from the code-block
{:ok, _, binary_module} = :compile.forms(forms)
{:module, new_module} = :code.load_binary(module_name, ~c"nofile", binary_module)

# Registration of module
md5 = apply(new_module, :module_info, [:md5])
# IO.inspect(%{:md5 => md5, :module => new_module})

# Add the newly defined erlang module
env = Map.put(env, :versioned_erlang_modules, %{new_module => md5})

{{:ok, ~c"erlang module successfully compiled", binding, env}, []}
else
# Tokenizer error - https://www.erlang.org/doc/man/erl_scan.html#string-3
{:error, {location, module, description}, _end_loc} ->
process_erlang_error(env, code, location, module, description)

# Parser error - https://www.erlang.org/doc/man/erl_parse.html#parse_form-1
{:error, {location, module, description}} ->
process_erlang_error(env, code, location, module, description)
end
catch
kind, error ->
stacktrace = prune_stacktrace(:erl_eval, __STACKTRACE__)
{{:error, kind, error, stacktrace}, []}
end
end

defp eval_statements(:erlang, code, binding, env) do
try do
erl_binding =
Enum.reduce(binding, %{}, fn {name, value}, erl_binding ->
Expand Down Expand Up @@ -869,6 +971,18 @@ defmodule Livebook.Runtime.Evaluator do
do: {{:module, module}, version},
into: identifiers_defined

# Erlang modules defined ? If so register - with md5
identifiers_defined =
case Map.has_key?(context.env, :versioned_erlang_modules) do
true ->
for {module, version} <- context.env.versioned_erlang_modules,
do: {{:module, module}, version},
into: identifiers_defined

false ->
identifiers_defined
end

# Aliases

identifiers_used =
Expand Down
47 changes: 47 additions & 0 deletions test/livebook/runtime/evaluator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,53 @@ defmodule Livebook.Runtime.EvaluatorTest do
assert_receive {:runtime_evaluation_response, :code_1, {: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,
{:text, "\"erlang module successfully compiled\""}, 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_output, :code_4,
{:stdout, ":1:52: function go/0 already defined\n"}}
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,
_ErrorText,
:other
},
%{code_markers: _List}
}
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