Skip to content

Commit fe6ee79

Browse files
authored
Merge pull request #1020 from Stratus3D/tb/nested-calls-check-fix
Fix bugs in Credo.Check.Readability.NestedFunctionCalls check
2 parents ab49200 + 5cfea9e commit fe6ee79

File tree

2 files changed

+298
-34
lines changed

2 files changed

+298
-34
lines changed

lib/credo/check/readability/nested_function_calls.ex

Lines changed: 265 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ defmodule Credo.Check.Readability.NestedFunctionCalls do
3333
]
3434
]
3535

36+
alias Credo.Check.Readability.NestedFunctionCalls.PipeHelper
3637
alias Credo.Code.Name
3738

3839
@doc false
@@ -42,55 +43,54 @@ defmodule Credo.Check.Readability.NestedFunctionCalls do
4243

4344
min_pipeline_length = Params.get(params, :min_pipeline_length, __MODULE__)
4445

45-
{_continue, issues} =
46+
{_min_pipeline_length, issues} =
4647
Credo.Code.prewalk(
4748
source_file,
48-
&traverse(&1, &2, issue_meta, min_pipeline_length),
49-
{true, []}
49+
&traverse(&1, &2, issue_meta),
50+
{min_pipeline_length, []}
5051
)
5152

5253
issues
5354
end
5455

55-
# A call with no arguments
56-
defp traverse({{:., _loc, _call}, _meta, []} = ast, {_, issues}, _, min_pipeline_length) do
57-
{ast, {min_pipeline_length, issues}}
56+
# A call in a pipeline
57+
defp traverse({:|>, _meta, [pipe_input, {{:., _meta2, _fun}, _meta3, args}]}, acc, _issue) do
58+
{[pipe_input, args], acc}
59+
end
60+
61+
# A fully qualified call with no arguments
62+
defp traverse({{:., _meta, _call}, _meta2, []} = ast, accumulator, _issue) do
63+
{ast, accumulator}
5864
end
5965

60-
# A call with arguments
66+
# Any call
6167
defp traverse(
62-
{{:., _loc, call}, meta, args} = ast,
63-
{_, issues},
64-
issue_meta,
65-
min_pipeline_length
68+
{{_name, _loc, call}, meta, args} = ast,
69+
{min_pipeline_length, issues} = acc,
70+
issue_meta
6671
) do
67-
if valid_chain_start?(ast) do
68-
{ast, {min_pipeline_length, issues}}
72+
if cannot_be_in_pipeline?(ast) do
73+
{ast, acc}
6974
else
7075
case length_as_pipeline(args) + 1 do
7176
potential_pipeline_length when potential_pipeline_length >= min_pipeline_length ->
72-
{ast,
73-
{min_pipeline_length, issues ++ [issue_for(issue_meta, meta[:line], Name.full(call))]}}
77+
new_issues = issues ++ [issue_for(issue_meta, meta[:line], Name.full(call))]
78+
{ast, {min_pipeline_length, new_issues}}
7479

7580
_ ->
76-
{ast, {min_pipeline_length, issues}}
81+
{nil, acc}
7782
end
7883
end
7984
end
8085

81-
# Another expression
82-
defp traverse(ast, {_, issues}, _issue_meta, min_pipeline_length) do
86+
# Another expression, we must no longer be in a pipeline
87+
defp traverse(ast, {min_pipeline_length, issues}, _issue_meta) do
8388
{ast, {min_pipeline_length, issues}}
8489
end
8590

86-
# Call with no arguments
87-
defp length_as_pipeline([{{:., _loc, _call}, _meta, []} | _]) do
88-
0
89-
end
90-
9191
# Call with function call for first argument
92-
defp length_as_pipeline([{{:., _loc, _call}, _meta, args} = call_ast | _]) do
93-
if valid_chain_start?(call_ast) do
92+
defp length_as_pipeline([{_name, _meta, args} = call_ast | _]) do
93+
if cannot_be_in_pipeline?(call_ast) do
9494
0
9595
else
9696
1 + length_as_pipeline(args)
@@ -111,15 +111,247 @@ defmodule Credo.Check.Readability.NestedFunctionCalls do
111111
)
112112
end
113113

114-
# Taken from the Credo.Check.Refactor.PipeChainStart module, with modifications
115-
# map[:access]
116-
defp valid_chain_start?({{:., _, [Access, :get]}, _, _}), do: true
114+
defp cannot_be_in_pipeline?(ast) do
115+
PipeHelper.cannot_be_in_pipeline?(ast, [], [])
116+
end
117+
118+
defmodule PipeHelper do
119+
@moduledoc """
120+
This module exists to contain logic for the cannot_be_in_pipline?/3 helper
121+
function. This function was originally copied from the
122+
Credo.Check.Refactor.PipeChainStart module's valid_chain_start?/3 function.
123+
Both functions are identical.
124+
"""
125+
126+
@elixir_custom_operators [
127+
:<-,
128+
:|||,
129+
:&&&,
130+
:<<<,
131+
:>>>,
132+
:<<~,
133+
:~>>,
134+
:<~,
135+
:~>,
136+
:<~>,
137+
:"<|>",
138+
:"^^^",
139+
:"~~~",
140+
:"..//"
141+
]
142+
143+
def cannot_be_in_pipeline?(
144+
{:__block__, _, [single_ast_node]},
145+
excluded_functions,
146+
excluded_argument_types
147+
) do
148+
cannot_be_in_pipeline?(
149+
single_ast_node,
150+
excluded_functions,
151+
excluded_argument_types
152+
)
153+
end
154+
155+
for atom <- [
156+
:%,
157+
:%{},
158+
:..,
159+
:<<>>,
160+
:@,
161+
:__aliases__,
162+
:unquote,
163+
:{},
164+
:&,
165+
:<>,
166+
:++,
167+
:--,
168+
:&&,
169+
:||,
170+
:+,
171+
:-,
172+
:*,
173+
:/,
174+
:>,
175+
:>=,
176+
:<,
177+
:<=,
178+
:==,
179+
:for,
180+
:with,
181+
:not,
182+
:and,
183+
:or
184+
] do
185+
def cannot_be_in_pipeline?(
186+
{unquote(atom), _meta, _arguments},
187+
_excluded_functions,
188+
_excluded_argument_types
189+
) do
190+
true
191+
end
192+
end
193+
194+
for operator <- @elixir_custom_operators do
195+
def cannot_be_in_pipeline?(
196+
{unquote(operator), _meta, _arguments},
197+
_excluded_functions,
198+
_excluded_argument_types
199+
) do
200+
true
201+
end
202+
end
203+
204+
# anonymous function
205+
def cannot_be_in_pipeline?(
206+
{:fn, _, [{:->, _, [_args, _body]}]},
207+
_excluded_functions,
208+
_excluded_argument_types
209+
) do
210+
true
211+
end
212+
213+
# function_call()
214+
def cannot_be_in_pipeline?(
215+
{atom, _, []},
216+
_excluded_functions,
217+
_excluded_argument_types
218+
)
219+
when is_atom(atom) do
220+
true
221+
end
222+
223+
# function_call(with, args) and sigils
224+
def cannot_be_in_pipeline?(
225+
{atom, _, arguments} = ast,
226+
excluded_functions,
227+
excluded_argument_types
228+
)
229+
when is_atom(atom) and is_list(arguments) do
230+
sigil?(atom) ||
231+
valid_chain_start_function_call?(
232+
ast,
233+
excluded_functions,
234+
excluded_argument_types
235+
)
236+
end
237+
238+
# map[:access]
239+
def cannot_be_in_pipeline?(
240+
{{:., _, [Access, :get]}, _, _},
241+
_excluded_functions,
242+
_excluded_argument_types
243+
) do
244+
true
245+
end
246+
247+
# Module.function_call()
248+
def cannot_be_in_pipeline?(
249+
{{:., _, _}, _, []},
250+
_excluded_functions,
251+
_excluded_argument_types
252+
),
253+
do: true
254+
255+
# Elixir <= 1.8.0
256+
# '__#{val}__' are compiled to String.to_charlist("__#{val}__")
257+
# we want to consider these charlists a valid pipe chain start
258+
def cannot_be_in_pipeline?(
259+
{{:., _, [String, :to_charlist]}, _, [{:<<>>, _, _}]},
260+
_excluded_functions,
261+
_excluded_argument_types
262+
),
263+
do: true
264+
265+
# Elixir >= 1.8.0
266+
# '__#{val}__' are compiled to String.to_charlist("__#{val}__")
267+
# we want to consider these charlists a valid pipe chain start
268+
def cannot_be_in_pipeline?(
269+
{{:., _, [List, :to_charlist]}, _, [[_ | _]]},
270+
_excluded_functions,
271+
_excluded_argument_types
272+
),
273+
do: true
274+
275+
# Module.function_call(with, parameters)
276+
def cannot_be_in_pipeline?(
277+
{{:., _, _}, _, _} = ast,
278+
excluded_functions,
279+
excluded_argument_types
280+
) do
281+
valid_chain_start_function_call?(
282+
ast,
283+
excluded_functions,
284+
excluded_argument_types
285+
)
286+
end
287+
288+
def cannot_be_in_pipeline?(_, _excluded_functions, _excluded_argument_types), do: true
289+
290+
def valid_chain_start_function_call?(
291+
{_atom, _, arguments} = ast,
292+
excluded_functions,
293+
excluded_argument_types
294+
) do
295+
function_name = to_function_call_name(ast)
296+
297+
found_argument_types =
298+
case arguments do
299+
[nil | _] -> [:atom]
300+
x -> x |> List.first() |> argument_type()
301+
end
302+
303+
Enum.member?(excluded_functions, function_name) ||
304+
Enum.any?(
305+
found_argument_types,
306+
&Enum.member?(excluded_argument_types, &1)
307+
)
308+
end
309+
310+
defp sigil?(atom) do
311+
atom
312+
|> to_string
313+
|> String.match?(~r/^sigil_[a-zA-Z]$/)
314+
end
315+
316+
defp to_function_call_name({_, _, _} = ast) do
317+
{ast, [], []}
318+
|> Macro.to_string()
319+
|> String.replace(~r/\.?\(.*\)$/s, "")
320+
end
321+
322+
@alphabet_wo_r ~w(a b c d e f g h i j k l m n o p q s t u v w x y z)
323+
@all_sigil_chars Enum.flat_map(@alphabet_wo_r, &[&1, String.upcase(&1)])
324+
@matchable_sigils Enum.map(@all_sigil_chars, &:"sigil_#{&1}")
325+
326+
for sigil_atom <- @matchable_sigils do
327+
defp argument_type({unquote(sigil_atom), _, _}) do
328+
[unquote(sigil_atom)]
329+
end
330+
end
117331

118-
# Module.function_call()
119-
defp valid_chain_start?({{:., _, _}, _, []}), do: true
332+
defp argument_type({:sigil_r, _, _}), do: [:sigil_r, :regex]
333+
defp argument_type({:sigil_R, _, _}), do: [:sigil_R, :regex]
120334

121-
# Kernel.to_string is invoked for string interpolation e.g. "string #{variable}"
122-
defp valid_chain_start?({{:., _, [Kernel, :to_string]}, _, _}), do: true
335+
defp argument_type({:fn, _, _}), do: [:fn]
336+
defp argument_type({:%{}, _, _}), do: [:map]
337+
defp argument_type({:{}, _, _}), do: [:tuple]
338+
defp argument_type(nil), do: []
123339

124-
defp valid_chain_start?(_), do: false
340+
defp argument_type(v) when is_atom(v), do: [:atom]
341+
defp argument_type(v) when is_binary(v), do: [:binary]
342+
defp argument_type(v) when is_bitstring(v), do: [:bitstring]
343+
defp argument_type(v) when is_boolean(v), do: [:boolean]
344+
345+
defp argument_type(v) when is_list(v) do
346+
if Keyword.keyword?(v) do
347+
[:keyword, :list]
348+
else
349+
[:list]
350+
end
351+
end
352+
353+
defp argument_type(v) when is_number(v), do: [:number]
354+
355+
defp argument_type(v), do: [:credo_type_error, v]
356+
end
125357
end

test/credo/check/readability/nested_function_calls_test.exs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,22 @@ defmodule Credo.Check.Readability.NestedFunctionCallsTest do
9494
|> refute_issues()
9595
end
9696

97-
test "it should NOT two nested functions calls when the inner function call takes no arguments" do
97+
test "it should NOT report nested function calls when the outer function is already in a pipeline" do
98+
"""
99+
defmodule CredoSampleModule do
100+
def some_code do
101+
[1,2,3,4]
102+
|> Test.test()
103+
|> Enum.map(SomeMod.some_fun(argument))
104+
end
105+
end
106+
"""
107+
|> to_source_file()
108+
|> run_check(NestedFunctionCalls)
109+
|> refute_issues()
110+
end
111+
112+
test "it should NOT report two nested functions calls when the inner function call takes no arguments" do
98113
"""
99114
defmodule CredoSampleModule do
100115
def some_code do
@@ -158,4 +173,21 @@ defmodule Credo.Check.Readability.NestedFunctionCallsTest do
158173
|> run_check(NestedFunctionCalls, min_pipeline_length: 3)
159174
|> refute_issues()
160175
end
176+
177+
test "it should report nested function calls inside a pipeline when the inner function calls could be a pipeline of their own" do
178+
"""
179+
defmodule CredoSampleModule do
180+
def some_code do
181+
[1,2,3,4]
182+
|> Test.test()
183+
|> Enum.map(fn(item) ->
184+
SomeMod.some_fun(another_fun(item))
185+
end)
186+
end
187+
end
188+
"""
189+
|> to_source_file()
190+
|> run_check(NestedFunctionCalls)
191+
|> assert_issue()
192+
end
161193
end

0 commit comments

Comments
 (0)