Packages
livebook
0.11.1
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook/runtime/evaluator/doctests.ex
defmodule Livebook.Runtime.Evaluator.Doctests do
@test_timeout 5_000
@line_width 80
@pad_size 3
@doc """
Runs doctests in the given modules.
"""
@spec run(list(module()), String.t()) :: :ok
def run(modules, code) do
doctests_specs =
for module <- modules, doctests_spec = doctests_spec(module), do: doctests_spec
do_run(doctests_specs, code)
end
defp doctests_spec(module) do
case Code.fetch_docs(module) do
{:docs_v1, _, _, _, doc_content, _, member_docs} ->
funs =
for {{:function, name, arity}, annotation, _signatures, _doc, _meta} <- member_docs,
do: %{name: name, arity: arity, generated: :erl_anno.generated(annotation)}
{generated_funs, regular_funs} = Enum.split_with(funs, & &1.generated)
if regular_funs != [] or is_map(doc_content) do
except = Enum.map(generated_funs, &{&1.name, &1.arity})
%{module: module, except: except}
end
_ ->
nil
end
end
defp do_run([], _code), do: :ok
defp do_run(doctests_specs, code) do
case define_test_module(doctests_specs) do
{:ok, test_module} ->
lines = String.split(code, ["\r\n", "\n"])
# Ignore test cases that don't actually point to a doctest
# in the source code
tests = Enum.filter(test_module.tests, &doctest_at_line?(lines, &1.tags.doctest_line))
if tests != [] do
tests
|> Enum.sort_by(& &1.tags.doctest_line)
|> Enum.each(fn test ->
report_doctest_running(test)
test = run_test(test)
report_doctest_result(test, lines)
end)
end
delete_test_module(test_module)
{:error, kind, error} ->
put_output({:error, colorize(:red, Exception.format(kind, error, [])), :other})
end
:ok
end
defp doctest_at_line?(lines, line_number) do
if line = Enum.at(lines, line_number - 1) do
case String.trim_leading(line) do
"iex>" <> _ -> true
"iex(" <> _ -> true
_ -> false
end
else
false
end
end
defp report_doctest_running(test) do
send_doctest_report(%{
line: test.tags.doctest_line,
status: :running
})
end
defp report_doctest_result(%{state: nil} = test, _lines) do
send_doctest_report(%{
line: test.tags.doctest_line,
status: :success
})
end
defp report_doctest_result(%{state: {:failed, failure}} = test, lines) do
doctest_line = test.tags.doctest_line
[prompt_line | _] = lines = Enum.drop(lines, doctest_line - 1)
end_line =
if end_line = test.tags[:doctest_data][:end_line] do
end_line
else
# TODO: Remove this branch once we require Elixir v1.15+
interval =
lines
|> Enum.take_while(&(not end_of_doctest?(&1)))
|> length()
interval + doctest_line - 1
end
end_line =
with {:error, %ExUnit.AssertionError{}, [{_, _, _, location} | _]} <- failure,
assertion_line = location[:line],
# TODO: Remove this check once we require Elixir v1.15+
true <- is_integer(test.tags[:doctest_data][:end_line]),
true <- assertion_line in doctest_line..end_line do
end_line -
length(
lines
|> Enum.take(end_line - doctest_line + 1)
|> Enum.drop(assertion_line - doctest_line)
|> Enum.drop_while(&prompt?(&1))
|> Enum.drop_while(&(not prompt?(&1)))
)
else
_ ->
end_line
end
send_doctest_report(%{
column: count_columns(prompt_line, 0),
line: doctest_line,
end_line: end_line,
status: :failed,
details: IO.iodata_to_binary(format_failure(failure, test))
})
end
defp count_columns(" " <> rest, counter), do: count_columns(rest, counter + 1)
defp count_columns("\t" <> rest, counter), do: count_columns(rest, counter + 2)
defp count_columns(_, counter), do: counter
defp prompt?(line) do
case String.trim_leading(line) do
"iex>" <> _ -> true
"iex(" <> _ -> true
"...>" <> _ -> true
"...(" <> _ -> true
_ -> false
end
end
defp end_of_doctest?(line) do
case String.trim_leading(line) do
"" -> true
"```" <> _ -> true
"~~~" <> _ -> true
"'''" <> _ -> true
"\"\"\"" <> _ -> true
_ -> false
end
end
defp define_test_module(doctests_specs) do
id =
doctests_specs
|> Enum.map(& &1.module)
|> Enum.sort()
|> Enum.map_join("-", fn module ->
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
end)
|> :erlang.md5()
|> Base.encode32(padding: false)
name = Module.concat([LivebookDoctest, "TestModule#{id}"])
try do
defmodule name do
use ExUnit.Case, register: false
for doctests_spec <- doctests_specs do
doctest doctests_spec.module, except: doctests_spec.except
end
end
{:ok, name.__ex_unit__()}
catch
kind, error -> {:error, kind, error}
end
end
defp delete_test_module(%{name: module}) do
:code.delete(module)
:code.purge(module)
end
defp run_test(test) do
runner_pid = self()
{test_pid, test_ref} =
spawn_monitor(fn ->
test = exec_test(test)
send(runner_pid, {:test_finished, self(), test})
end)
receive_test_reply(test, test_pid, test_ref)
end
defp receive_test_reply(test, test_pid, test_ref) do
receive do
{:test_finished, ^test_pid, test} ->
Process.demonitor(test_ref, [:flush])
test
{:DOWN, ^test_ref, :process, ^test_pid, error} ->
%{test | state: failed({:EXIT, test_pid}, error, [])}
after
@test_timeout ->
case Process.info(test_pid, :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
Process.exit(test_pid, :kill)
receive do
{:DOWN, ^test_ref, :process, ^test_pid, _} -> :ok
end
exception = RuntimeError.exception("doctest timed out after #{@test_timeout} ms")
%{test | state: failed(:error, exception, prune_stacktrace(stacktrace))}
nil ->
receive_test_reply(test, test_pid, test_ref)
end
end
end
defp exec_test(%ExUnit.Test{module: module, name: name} = test) do
apply(module, name, [:none])
test
catch
kind, error ->
%{test | state: failed(kind, error, prune_stacktrace(__STACKTRACE__))}
end
defp failed(kind, reason, stack) do
{:failed, {kind, Exception.normalize(kind, reason, stack), stack}}
end
# As soon as we see our runner module, we ignore the rest of the stacktrace
def prune_stacktrace([{__MODULE__, _, _, _} | _]), do: []
def prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)]
def prune_stacktrace([]), do: []
# Formatting
defp format_failure({:error, %ExUnit.AssertionError{} = reason, _stack}, _test) do
diff =
ExUnit.Formatter.format_assertion_diff(
reason,
@pad_size + 2,
@line_width,
&diff_formatter/2
)
expected = diff[:right]
got = diff[:left]
{expected_label, got_label} =
if reason.doctest == ExUnit.AssertionError.no_value() do
{"right", "left"}
else
{"expected", "got"}
end
message_io =
if_io(reason.message != "Doctest failed", fn ->
message = String.replace_prefix(reason.message, "Doctest failed: ", "")
colorize(:red, message)
end)
expected_io =
if_io(expected, fn ->
[format_label(expected_label), "\n ", expected]
end)
got_io =
if_io(got, fn ->
[format_label(got_label), "\n ", got]
end)
[message_io, expected_io, got_io]
|> Enum.filter(&(&1 != []))
|> Enum.intersperse("\n")
end
defp format_failure({kind, reason, stacktrace}, test) do
{blamed, stacktrace} = Exception.blame(kind, reason, stacktrace)
banner =
case blamed do
%FunctionClauseError{} ->
banner = Exception.format_banner(kind, reason, stacktrace)
inspect_opts = Livebook.Runtime.Evaluator.Formatter.inspect_opts()
blame = FunctionClauseError.blame(blamed, &inspect(&1, inspect_opts), &blame_match/1)
colorize(:red, banner) <> blame
_ ->
banner = Exception.format_banner(kind, blamed, stacktrace)
colorize(:red, banner)
end
if stacktrace == [] do
banner
else
[
banner,
"\n",
format_label("stacktrace"),
format_stacktrace(stacktrace, test.module, test.name)
]
end
end
defp if_io(value, fun), do: if(value, do: fun.(), else: [])
defp format_stacktrace(stacktrace, test_case, test) do
for entry <- stacktrace do
message = format_stacktrace_entry(entry, test_case, test)
"\n" <> pad(message, 2)
end
end
defp format_stacktrace_entry({test_case, test, _, location}, test_case, test) do
"doctest at line #{location[:line]}"
end
defp format_stacktrace_entry(entry, _test_case, _test) do
Exception.format_stacktrace_entry(entry)
end
defp format_label(label), do: colorize(:cyan, "#{label}:")
defp pad(string, pad_size) do
padding = String.duplicate(" ", pad_size)
padding <> String.replace(string, "\n", "\n" <> padding)
end
defp blame_match(%{match?: true, node: node}), do: Macro.to_string(node)
defp blame_match(%{match?: false, node: node}) do
colorize(:red, Macro.to_string(node))
end
defp diff_formatter(:diff_enabled?, _doc), do: true
defp diff_formatter(key, doc) do
colors = [
diff_delete: :red,
diff_delete_whitespace: IO.ANSI.color_background(4, 0, 0),
diff_insert: :green,
diff_insert_whitespace: IO.ANSI.color_background(0, 3, 0)
]
Inspect.Algebra.color(doc, key, %Inspect.Opts{syntax_colors: colors})
end
defp colorize(color, string) do
[color, string, :reset]
|> IO.ANSI.format_fragment(true)
|> IO.iodata_to_binary()
end
defp put_output(output) do
send_livebook_message({:livebook_put_output, output})
end
defp send_doctest_report(doctest_report) do
send_livebook_message({:livebook_doctest_report, doctest_report})
end
defp send_livebook_message(message) do
gl = Process.group_leader()
ref = make_ref()
send(gl, {:io_request, self(), ref, message})
receive do
{:io_reply, ^ref, reply} -> {:ok, reply}
end
end
end