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

Closed
wants to merge 2 commits into from
Closed
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 @@ -688,7 +688,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

Copy link

Choose a reason for hiding this comment

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

For visibility @josevalim @fnchooft Did a PR for this here!
fnchooft#1


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 @@ -898,6 +1000,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 @@ -1300,6 +1300,53 @@
assert_receive {:runtime_evaluation_response, :code_1, terminal_text("6"), metadata()}
end

test "evaluate erlang-module code", %{evaluator: evaluator} do

Check failure on line 1303 in test/livebook/runtime/evaluator_test.exs

View workflow job for this annotation

GitHub Actions / main

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

assert_receive {:runtime_evaluation_response, :code_4,
Copy link

@guisaez guisaez Sep 27, 2024

Choose a reason for hiding this comment

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

@fnchooft Again, I'm not very faimiliar with the test logic in Elixir, but I noticed that for most tests it actually returns a map, not a tuple. i changed this lines to

assert_receive {:runtime_evaluation_response, :code_4,
                      terminal_text("\"erlang module successfully compiled\""), metadata()}

{:text, "\"erlang module successfully compiled\""}, metadata()}
end

test "evaluate erlang-module error function already defined", %{evaluator: evaluator} do

Check failure on line 1316 in test/livebook/runtime/evaluator_test.exs

View workflow job for this annotation

GitHub Actions / main

test erlang evaluation evaluate erlang-module error function already defined (Livebook.Runtime.EvaluatorTest)
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,
Copy link

@guisaez guisaez Sep 27, 2024

Choose a reason for hiding this comment

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

assert_receive {:runtime_evaluation_output, :code_4,
                      terminal_text(":1:52: function go/0 already defined\n", true)}

{:stdout, ":1:52: function go/0 already defined\n"}}
end

test "evaluate erlang-module error - expression after module", %{evaluator: evaluator} do

Check failure on line 1329 in test/livebook/runtime/evaluator_test.exs

View workflow job for this annotation

GitHub Actions / main

test erlang evaluation evaluate erlang-module error - expression after module (Livebook.Runtime.EvaluatorTest)
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,
{
Copy link

@guisaez guisaez Sep 27, 2024

Choose a reason for hiding this comment

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

assert_receive {
        :runtime_evaluation_response,
        :code_4,
        error(_),
        %{code_markers: _List}
      }

: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
Loading