Skip to content

Commit

Permalink
New analyzer function: assert_call (#49)
Browse files Browse the repository at this point in the history
Adds a function analyzer clauses to assert that a function is called within a module function, or to assert that a function is not called.
  • Loading branch information
neenjaw authored Apr 3, 2021
1 parent 3b1d134 commit bd7dc58
Show file tree
Hide file tree
Showing 12 changed files with 808 additions and 228 deletions.
5 changes: 2 additions & 3 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
form: :*,
suppress_if: :*,
depth: :*,
called_from: :*,
global_call: :*,
local_call: :*,
calling_fn: :*,
called_fn: :*,
should_be_present: :*,
type: :*
]
Expand Down
185 changes: 16 additions & 169 deletions lib/elixir_analyzer/exercise_test.ex
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
defmodule ElixirAnalyzer.ExerciseTest do
@moduledoc false

alias ElixirAnalyzer.ExerciseTest.Feature.Compiler, as: FeatureCompiler
alias ElixirAnalyzer.ExerciseTest.AssertCall.Compiler, as: AssertCallCompiler

alias ElixirAnalyzer.Submission
alias ElixirAnalyzer.QuoteUtil
alias ElixirAnalyzer.Constants

@doc false
defmacro __using__(_opts) do
quote do
use ElixirAnalyzer.ExerciseTest.Feature
use ElixirAnalyzer.ExerciseTest.AssertCall

import unquote(__MODULE__)
@before_compile unquote(__MODULE__)
Expand All @@ -21,22 +24,28 @@ defmodule ElixirAnalyzer.ExerciseTest do

defmacro __before_compile__(env) do
feature_test_data = Macro.escape(Module.get_attribute(env.module, :feature_tests))
assert_call_data = Module.get_attribute(env.module, :assert_call_tests)

# ast placeholder for the submission code ast
code_ast = quote do: code_ast

# compile each feature to a test
feature_tests = Enum.map(feature_test_data, &compile_feature(&1, code_ast))
feature_tests = Enum.map(feature_test_data, &FeatureCompiler.compile(&1, code_ast))

# compile each assert_call to a test
assert_call_tests = Enum.map(assert_call_data, &AssertCallCompiler.compile(&1, code_ast))

quote do
@spec analyze(Submission.t(), String.t()) :: Submission.t()
def analyze(submission = %Submission{}, code_as_string) do
case Code.string_to_quoted(code_as_string) do
{:ok, code_ast} ->
feature_results = unquote(feature_tests)
feature_results = filter_suppressed_results(feature_results)
feature_results = unquote(feature_tests) |> filter_suppressed_results()
assert_call_results = unquote(assert_call_tests)

append_test_comments(submission, feature_results)
submission
|> append_test_comments(feature_results)
|> append_test_comments(assert_call_results)

{:error, e} ->
append_analysis_failure(submission, e)
Expand All @@ -61,8 +70,8 @@ defmodule ElixirAnalyzer.ExerciseTest do
end)
end

defp append_test_comments(submission = %Submission{}, feature_results) do
Enum.reduce(feature_results, submission, fn
defp append_test_comments(submission = %Submission{}, results) do
Enum.reduce(results, submission, fn
{:skip, _description}, submission ->
submission

Expand Down Expand Up @@ -104,166 +113,4 @@ defmodule ElixirAnalyzer.ExerciseTest do
end
end
end

def compile_feature({feature_data, feature_forms}, code_ast) do
name = Keyword.fetch!(feature_data, :name)
comment = Keyword.fetch!(feature_data, :comment)
status = Keyword.get(feature_data, :status, :test)
type = Keyword.get(feature_data, :type, :informative)
find_type = Keyword.get(feature_data, :find, :all)
find_at_depth = Keyword.get(feature_data, :depth, nil)
suppress_if = Keyword.get(feature_data, :suppress_if, false)

form_expr =
feature_forms
|> Enum.map(&compile_form(&1, find_at_depth, code_ast))
|> Enum.reduce(:start, &combine_compiled_forms(find_type, &1, &2))
|> handle_combined_compiled_forms(find_type)

test_description =
Macro.escape(%{
name: name,
comment: comment,
status: status,
type: type,
suppress_if: suppress_if
})

case status do
:test ->
quote do
if unquote(form_expr) do
{:pass, unquote(test_description)}
else
{:fail, unquote(test_description)}
end
end

:skip ->
quote do
{:skip, unquote(test_description)}
end
end
end

def compile_form(form, find_at_depth, code_ast) do
find_ast_string = Keyword.fetch!(form, :find_ast_string)
block_params = Keyword.fetch!(form, :block_params)

find_ast = Code.string_to_quoted!(find_ast_string)

# create the walk function, determined if the form to find
# is multiple first level entries in a code block
walk_fn = get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth)

quote do
(fn ast ->
{_, result} = QuoteUtil.prewalk(ast, false, unquote(walk_fn))

result
end).(unquote(code_ast))
end
end

defp get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth)
when is_integer(block_params) do
quote do
fn
# If the node matches a block, then chunk the block contents
# to the size of the form block, then pattern match on each chunk
# return true if a match
{:__block__, _, params} = node, false, depth ->
finding_depth = unquote(find_at_depth) in [nil, depth]

cond do
finding_depth and is_list(params) ->
found =
params
|> Enum.chunk_every(unquote(block_params), 1, :discard)
|> Enum.reduce(false, fn
chunk, false ->
match?(unquote(find_ast), chunk)

_chunk, true ->
true
end)

{node, found}

true ->
{node, false}
end

# If not a block, then we know it can't match, so pass
# along the accumulator
node, val, _depth ->
{node, val}
end
end
end

defp get_compile_form_prewalk_fn(false, find_ast, find_at_depth) do
quote do
fn
node, false, depth ->
finding_depth = unquote(find_at_depth) in [nil, depth]

cond do
finding_depth ->
{node, match?(unquote(find_ast), node)}

true ->
{node, false}
end

node, true, _depth ->
{node, true}
end
end
end

def combine_compiled_forms(:any, form, :start) do
# start the disjunction with false
combine_compiled_forms(:any, form, quote(do: false))
end

def combine_compiled_forms(:any, form, expr) do
quote do: unquote(form) or unquote(expr)
end

def combine_compiled_forms(:all, form, :start) do
# start the conjunction with true
combine_compiled_forms(:all, form, quote(do: true))
end

def combine_compiled_forms(:all, form, expr) do
quote do: unquote(form) and unquote(expr)
end

def combine_compiled_forms(type, form, :start) when type in [:one, :none] do
combine_compiled_forms(type, form, quote(do: 0))
end

def combine_compiled_forms(type, form, expr) when type in [:one, :none] do
quote do
value =
(fn form_val ->
if form_val do
1
else
0
end
end).(unquote(form))

value + unquote(expr)
end
end

def handle_combined_compiled_forms(combined_expr, find_type) do
case find_type do
type when type in [:all, :any] -> combined_expr
type when type in [:none] -> quote do: unquote(combined_expr) == 0
type when type in [:one] -> quote do: unquote(combined_expr) == 1
end
end
end
137 changes: 137 additions & 0 deletions lib/elixir_analyzer/exercise_test/assert_call.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
defmodule ElixirAnalyzer.ExerciseTest.AssertCall do
@moduledoc false

@type function_signature() :: {list(atom()), atom()} | {nil, atom()}

@doc false
defmacro __using__(_opts) do
quote do
import unquote(__MODULE__)

@assert_call_tests []
end
end

@doc """
Defines a macro which allows a test case to be specified which looks for a
function call inside of a specific function.
This macro then collates the block into a map structure resembling:
test_data = %{
description: description,
calling_function_module: %FunctionSignature{}
called_function_module: %FunctionSignature{}
should_call: true
type: :actionable
comment: "message"
}
"""
defmacro assert_call(description, do: block) do
parse(description, block, true)
end

defmacro assert_no_call(description, do: block) do
parse(description, block, false)
end

defp parse(description, block, should_call) do
test_data =
block
|> walk_assert_call_block()
|> Map.put(:description, description)
|> Map.put(:should_call, should_call)
|> Map.put_new(:type, :informational)
|> Map.put_new(:calling_fn, nil)

unless Map.has_key?(test_data, :comment) do
raise "Comment must be defined for each assert_call test"
end

quote do
@assert_call_tests [
unquote(Macro.escape(test_data)) | @assert_call_tests
]
end
end

defp walk_assert_call_block(block, test_data \\ %{}) do
{_, test_data} = Macro.prewalk(block, test_data, &do_walk_assert_call_block/2)
test_data
end

defp do_walk_assert_call_block({:calling_fn, _, [signature]} = node, test_data) do
formatted_signature = do_calling_fn(signature)
{node, Map.put(test_data, :calling_fn, formatted_signature)}
end

defp do_walk_assert_call_block({:called_fn, _, [signature]} = node, test_data) do
formatted_signature = do_called_fn(signature)
{node, Map.put(test_data, :called_fn, formatted_signature)}
end

defp do_walk_assert_call_block({:comment, _, [comment]} = node, test_data)
when is_binary(comment) do
{node, Map.put(test_data, :comment, comment)}
end

defp do_walk_assert_call_block({:type, _, [type]} = node, test_data)
when type in ~w[essential actionable informational celebratory]a do
{node, Map.put(test_data, :type, type)}
end

defp do_walk_assert_call_block(node, test_data) do
{node, test_data}
end

@spec do_calling_fn(Keyword.t()) :: function_signature()
defp do_calling_fn(function_signature) do
case validate_signature(function_signature) do
{nil, _} ->
raise ArgumentError, "calling function signature requires :module to be an atom"

signature ->
signature
end
end

@spec do_called_fn(Keyword.t()) :: function_signature()
defp do_called_fn(function_signature) do
validate_signature(function_signature)
end

@spec validate_signature(Keyword.t()) :: function_signature()
defp validate_signature(function_signature) do
function_signature =
function_signature
|> validate_module()
|> validate_name()

{function_signature[:module], function_signature[:name]}
end

defp validate_module(signature) do
module =
case signature[:module] do
nil ->
nil

{:__aliases__, _, module} ->
module

_ ->
raise ArgumentError,
"calling function signature requires :module to be nil or a module atom"
end

Keyword.put(signature, :module, module)
end

defp validate_name(signature) do
module =
case signature[:name] do
name when is_atom(name) -> name
_ -> raise ArgumentError, "calling function signature requires :name to be an atom"
end

Keyword.put(signature, :name, module)
end
end
Loading

0 comments on commit bd7dc58

Please sign in to comment.