Skip to content

Commit

Permalink
[#137] assert_call detects indirect calls from helper functions (#145)
Browse files Browse the repository at this point in the history
  • Loading branch information
jiegillet authored Sep 12, 2021
1 parent e995d34 commit d448bdf
Show file tree
Hide file tree
Showing 5 changed files with 284 additions and 39 deletions.
67 changes: 60 additions & 7 deletions lib/elixir_analyzer/exercise_test/assert_call/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
modules_in_scope: %{},
found_called: false,
called_fn: called_fn,
calling_fn: calling_fn
calling_fn: calling_fn,
function_call_tree: %{}
}

ast
Expand All @@ -69,7 +70,9 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
Handle the final result from the assert function
"""
@spec handle_traverse_result({any, map()}) :: boolean
def handle_traverse_result({_, %{found_called: found}}), do: found
def handle_traverse_result({_, %{found_called: found, calling_fn: calling_fn} = acc}) do
found or (not is_nil(calling_fn) and indirect_call?(acc))
end

@doc """
When pre-order traversing, annotate the accumulator that we are now inside of a function definition
Expand All @@ -81,6 +84,7 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
acc
|> track_aliases(node)
|> track_imports(node)
|> track_all_functions(node)

cond do
module_def?(node) -> {node, %{acc | in_module: extract_module_name(node)}}
Expand Down Expand Up @@ -117,7 +121,8 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
in_function_modules: in_function_modules,
called_fn: called_fn,
calling_fn: calling_fn,
in_function_def: name
in_function_def: name,
function_call_tree: tree
} = acc
) do
modules = Map.merge(modules_in_scope, in_function_modules)
Expand All @@ -128,10 +133,15 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do

match_calling_fn? = in_function?({module, name}, calling_fn) or is_nil(calling_fn)

if match_called_fn? and match_calling_fn? do
{node, %{acc | found_called: true}}
else
{node, acc}
cond do
match_called_fn? and match_calling_fn? ->
{node, %{acc | found_called: true}}

match_called_fn? ->
{node, %{acc | function_call_tree: Map.put(tree, {module, name}, [called_fn])}}

true ->
{node, acc}
end
end

Expand Down Expand Up @@ -373,4 +383,47 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
else: %{acc | modules_in_scope: Map.put(acc.modules_in_scope, alias, full_path)}
end)
end

# track all called functions
def track_all_functions(
%{function_call_tree: tree, in_module: module, in_function_def: name} = acc,
{_, _, _} = function
)
when not is_nil(name) do
called =
case function do
{:., _, [{:__MODULE__, _, _}, fn_name]} -> {module, fn_name}
{:., _, [{:__aliases__, _, fn_module}, fn_name]} -> {fn_module, fn_name}
{fn_name, _, _} -> {module, fn_name}
end

%{acc | function_call_tree: Map.update(tree, {module, name}, [called], &[called | &1])}
end

def track_all_functions(acc, _node), do: acc

# Check if a function was called through helper functions
def indirect_call?(%{called_fn: called_fn, calling_fn: calling_fn, function_call_tree: tree}) do
cond do
# calling_fn wasn't defined in the code, or was searched already
is_nil(tree[calling_fn]) ->
false

# calling_fn directly called called_fn
called_fn in tree[calling_fn] ->
true

# calling_fn didn't call called_fn, recursively check if other called functions did
true ->
Enum.any?(
tree[calling_fn],
&indirect_call?(%{
called_fn: called_fn,
calling_fn: &1,
# Remove tree branch since we know it doesn't call called_fn
function_call_tree: Map.delete(tree, calling_fn)
})
)
end
end
end
144 changes: 144 additions & 0 deletions test/elixir_analyzer/exercise_test/assert_call/indirect_call_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
defmodule ElixirAnalyzer.ExerciseTest.AssertCall.IndirectCallTest do
use ElixirAnalyzer.ExerciseTestCase,
exercise_test_module: ElixirAnalyzer.Support.AnalyzerVerification.AssertCall.IndirectCall

test_exercise_analysis "Calling functions from main_function/0",
comments_exclude: [
"didn't find any call to Elixir.Mix.Utils.read_path/1 from main_function/0",
"didn't find any call to :math.pi from main_function/0",
"didn't find any call to final_function/1 from main_function/0"
] do
[
defmodule AssertCallVerification do
def main_function() do
file = Elixir.Mix.Utils.read_path("")
do_something(file)
final_function(:math.pi())
end
end,
# via helper
defmodule AssertCallVerification do
def main_function() do
helper("")
|> do_something()
end

def helper(path) do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end,
# via two helpers
defmodule AssertCallVerification do
def main_function() do
helper("")
|> do_something()
end

def helper(path) do
helper_2(path)
end

def helper_2(path) do
Elixir.Mix.Utils.read_path(path)

:math.pi()
|> final_function
end
end,
# via three helpers
defmodule AssertCallVerification do
def main_function() do
helper("")
|> do_something()
end

def helper(path) do
helper_2(path)
end

def helper_2(path) do
helper_3(path)
end

def helper_3(path) do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end,
# Full path for the helper function
defmodule AssertCallVerification do
def main_function() do
AssertCallVerification.helper("")
|> do_something()
end

def helper(path) do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end,
# __MODULE__ for the helper function
defmodule AssertCallVerification do
def main_function() do
__MODULE__.helper("")
|> do_something()
end

def helper(path) do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end
]
end

test_exercise_analysis "Not calling functions from main_function/0",
comments_include: [
"didn't find any call to Elixir.Mix.Utils.read_path/1 from main_function/0",
"didn't find any call to :math.pi from main_function/0",
"didn't find any call to final_function/1 from main_function/0"
] do
[
defmodule AssertCallVerification do
def main_function() do
end
end,
# recursion is safe
defmodule AssertCallVerification do
def main_function() do
:ok
|> main_function()
|> main_function()
|> do_something()
end
end,
defmodule AssertCallVerification do
def main_function() do
end

def unrelated_function() do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end,
defmodule AssertCallVerification do
# Internal modules don't fool assert_call
defmodule UnrelateInternaldModule do
def main_function() do
helper("")
|> do_something()
end

def helper(path) do
Elixir.Mix.Utils.read_path(path)
final_function(:math.pi())
end
end

def main_function() do
end
end
]
end
end
20 changes: 6 additions & 14 deletions test/elixir_analyzer/exercise_test/assert_call_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,8 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCallTest do
end
end

test_exercise_analysis "missing local call from specific function solution",
comments: [
"didn't find a local call to helper/0 within function/0",
"didn't find a local call to private_helper/0 within function/0"
] do
test_exercise_analysis "indirect call via a helper function",
comments: [] do
defmodule AssertCallVerification do
def function() do
x = List.first([1, 2, 3])
Expand Down Expand Up @@ -93,10 +90,8 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCallTest do
end
end

test_exercise_analysis "missing call to IO.puts/1 in function/0 solution",
comments: [
"didn't find a call to IO.puts/1 in function/0"
] do
test_exercise_analysis "inderect call to IO.puts/1 in function/0 via helper function",
comments: [] do
defmodule AssertCallVerification do
def function() do
l = List.first([1, 2, 3])
Expand All @@ -118,11 +113,8 @@ defmodule ElixirAnalyzer.ExerciseTest.AssertCallTest do
end
end

test_exercise_analysis "missing call to a List function in function/0 solution",
comments: [
"didn't find a call to a List function in function/0",
"mock.constant"
] do
test_exercise_analysis "indirect call to a List function in function/0 via helper function",
comments: [] do
defmodule AssertCallVerification do
def function() do
result = helper()
Expand Down
63 changes: 45 additions & 18 deletions test/elixir_analyzer/test_suite/freelancer_rates_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,55 @@ defmodule ElixirAnalyzer.ExerciseTest.FreelancerRatesTest do

test_exercise_analysis "example solution",
comments: [] do
defmodule FreelancerRates do
def daily_rate(hourly_rate) do
hourly_rate * 8.0
end
[
defmodule FreelancerRates do
def daily_rate(hourly_rate) do
hourly_rate * 8.0
end

def apply_discount(before_discount, discount) do
before_discount - before_discount * (discount / 100.0)
end
def apply_discount(before_discount, discount) do
before_discount - before_discount * (discount / 100.0)
end

def monthly_rate(hourly_rate, discount) do
monthly_rate_before_discount = daily_rate(hourly_rate) * 22.0
monthly_rate_after_discount = apply_discount(monthly_rate_before_discount, discount)
trunc(Float.ceil(monthly_rate_after_discount))
end
def monthly_rate(hourly_rate, discount) do
monthly_rate_before_discount = daily_rate(hourly_rate) * 22.0
monthly_rate_after_discount = apply_discount(monthly_rate_before_discount, discount)
trunc(Float.ceil(monthly_rate_after_discount))
end

def days_in_budget(budget, hourly_rate, discount) do
daily_rate_before_discount = daily_rate(hourly_rate)
daily_rate_after_discount = apply_discount(daily_rate_before_discount, discount)
days_in_budget = budget / daily_rate_after_discount
Float.floor(days_in_budget, 1)
def days_in_budget(budget, hourly_rate, discount) do
daily_rate_before_discount = daily_rate(hourly_rate)
daily_rate_after_discount = apply_discount(daily_rate_before_discount, discount)
days_in_budget = budget / daily_rate_after_discount
Float.floor(days_in_budget, 1)
end
end,
defmodule FreelancerRates do
def daily_rate(hourly_rate) do
hourly_rate * 8.0
end

def apply_discount(before_discount, discount) do
before_discount - before_discount * (discount / 100.0)
end

defp discounted_daily_rate(hourly_rate, discount) do
hourly_rate
|> apply_discount(discount)
|> daily_rate()
end

def monthly_rate(hourly_rate, discount) do
ceil(22 * discounted_daily_rate(hourly_rate, discount))
end

def days_in_budget(budget, hourly_rate, discount) do
daily_rate = discounted_daily_rate(hourly_rate, discount)

Float.floor(budget / daily_rate, 1)
end
end
end
]
end

describe "apply_discount/2 function reuse" do
Expand Down
29 changes: 29 additions & 0 deletions test/support/analyzer_verification/assert_call/indirect_call.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule ElixirAnalyzer.Support.AnalyzerVerification.AssertCall.IndirectCall do
@moduledoc """
This is an exercise analyzer extension module to test assert_call calling a function from
a calling function via helper functions
"""

use ElixirAnalyzer.ExerciseTest

assert_call "find a call to Elixir.Mix.Utils.read_path/1 from main_function/0" do
type :informational
called_fn module: Elixir.Mix.Utils, name: :read_path
calling_fn module: AssertCallVerification, name: :main_function
comment "didn't find any call to Elixir.Mix.Utils.read_path/1 from main_function/0"
end

assert_call "find a call to :math.pi from main_function/0" do
type :informational
called_fn module: :math, name: :pi
calling_fn module: AssertCallVerification, name: :main_function
comment "didn't find any call to :math.pi from main_function/0"
end

assert_call "find a call to final_function/1 from main_function/0" do
type :informational
called_fn name: :final_function
calling_fn module: AssertCallVerification, name: :main_function
comment "didn't find any call to final_function/1 from main_function/0"
end
end

0 comments on commit d448bdf

Please sign in to comment.