• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

exercism / elixir-analyzer / 81bb2bd44c91598e28b337a76394d2632f36db5b

29 Sep 2025 12:19AM UTC coverage: 98.524%. Remained the same
81bb2bd44c91598e28b337a76394d2632f36db5b

push

github

web-flow
Add analyzer for gotta-snatch-em-all (#451)

* Add analyzer for gotta-snatch-em-all

* Fix space alignment & formatting

* Add tests for gotta snatch em all analyzer

* Fix wording & spelling mistakes

Co-authored-by: Jie <jie.gillet@gmail.com>

---------

Co-authored-by: Jie <jie.gillet@gmail.com>

868 of 881 relevant lines covered (98.52%)

16232.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.27
/lib/elixir_analyzer/exercise_test/assert_call/compiler.ex
1
defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do
2
  @moduledoc """
3
  Provides the logic of the analyzer function `assert_call`
4

5
  When transformed at compile-time by `use ElixirAnalyzer.ExerciseTest`, this will place an expression inside
6
  of an if statement which then returns :pass or :fail as required by `ElixirAnalyzer.ExerciseTest.analyze/4`.
7
  """
8

9
  alias ElixirAnalyzer.ExerciseTest.AssertCall
10
  alias ElixirAnalyzer.Comment
11

12
  def compile(assert_call_data, code_ast) do
13
    name = Keyword.fetch!(assert_call_data, :description)
40✔
14
    called_fn = Keyword.fetch!(assert_call_data, :called_fn)
40✔
15
    calling_fn = Keyword.fetch!(assert_call_data, :calling_fn)
40✔
16
    comment = Keyword.fetch!(assert_call_data, :comment)
40✔
17
    should_call = Keyword.fetch!(assert_call_data, :should_call)
40✔
18
    type = Keyword.fetch!(assert_call_data, :type)
40✔
19
    suppress_if = Keyword.get(assert_call_data, :suppress_if, [])
40✔
20

21
    test_description =
40✔
22
      Macro.escape(%Comment{
23
        name: name,
24
        comment: comment,
25
        type: type,
26
        suppress_if: suppress_if
27
      })
28

29
    assert_result = assert_expr(code_ast, should_call, called_fn, calling_fn)
40✔
30

31
    quote do
32
      if unquote(assert_result) do
33
        {:pass, unquote(test_description)}
34
      else
35
        {:fail, unquote(test_description)}
36
      end
37
    end
38
  end
39

40
  defp assert_expr(code_ast, should_call, called_fn, calling_fn) do
41
    quote do
42
      (fn
43
         ast, true ->
44
           unquote(__MODULE__).assert(ast, unquote(called_fn), unquote(calling_fn))
45

46
         ast, false ->
47
           not unquote(__MODULE__).assert(ast, unquote(called_fn), unquote(calling_fn))
48
       end).(unquote(code_ast), unquote(should_call))
49
    end
50
  end
51

52
  def assert(ast, called_fn, calling_fn) do
53
    acc = %{
5,599✔
54
      in_module: nil,
55
      in_function_def: nil,
56
      in_macro_def: nil,
57
      in_function_or_macro_modules: %{},
58
      modules_in_scope: %{[:Kernel] => Kernel.module_info(:exports)},
59
      found_called: false,
60
      called_fn: called_fn,
61
      calling_fn: calling_fn,
62
      function_call_tree: %{}
63
    }
64

65
    ast
66
    |> Macro.traverse(acc, &annotate/2, &annotate_and_find/2)
67
    |> handle_traverse_result()
5,599✔
68
  end
69

70
  @doc """
71
  Handle the final result from the assert function
72
  """
73
  @spec handle_traverse_result({any, map()}) :: boolean
74
  def handle_traverse_result({_, %{found_called: found, calling_fn: calling_fn} = acc}) do
75
    found or (not is_nil(calling_fn) and indirect_call?(acc))
5,599✔
76
  end
77

78
  @doc """
79
  When pre-order traversing, annotate the accumulator that we are now inside of a function definition
80
  if it matches the calling_fn function signature
81
  """
82
  @spec annotate(Macro.t(), map()) :: {Macro.t(), map()}
83
  def annotate(node, acc) do
84
    node =
272,080✔
85
      node
86
      |> annotate_piped_functions
87
      |> annotate_defdelegate
88

89
    acc =
272,080✔
90
      acc
91
      |> track_aliases(node)
92
      |> track_imports(node)
93

94
    cond do
272,080✔
95
      module_def?(node) ->
5,598✔
96
        {node, %{acc | in_module: extract_module_name(node)}}
97

98
      function_def?(node) ->
266,482✔
99
        {node, %{acc | in_function_def: extract_function_or_macro_name(node)}}
100

101
      macro_def?(node) ->
255,138✔
102
        {node, %{acc | in_macro_def: extract_function_or_macro_name(node)}}
103

104
      true ->
255,068✔
105
        {node, acc}
106
    end
107
  end
108

109
  @doc """
110
  When post-order traversing, annotate the accumulator that we are now leaving a function definition
111
  """
112
  @spec annotate_and_find(Macro.t(), map()) :: {Macro.t(), map()}
113
  def annotate_and_find(node, acc) do
114
    {node, acc} = find(node, acc)
272,080✔
115

116
    cond do
272,080✔
117
      module_def?(node) ->
5,598✔
118
        {node, %{acc | in_module: nil}}
119

120
      function_def?(node) ->
266,482✔
121
        {node, %{acc | in_function_def: nil, in_function_or_macro_modules: %{}}}
122

123
      module_def?(node) ->
255,138✔
124
        {node, %{acc | in_macro_def: nil, in_function_or_macro_modules: %{}}}
125

126
      true ->
255,138✔
127
        {node, acc}
128
    end
129
  end
130

131
  @doc """
132
  While traversing the AST, compare a node to check if it is a function call matching the called_fn
133
  """
134
  @spec find(Macro.t(), map()) :: {Macro.t(), map()}
135
  def find(node, %{found_called: true} = acc), do: {node, acc}
24,558✔
136

137
  def find(
138
        node,
139
        %{
140
          in_module: module,
141
          modules_in_scope: modules_in_scope,
142
          in_function_or_macro_modules: in_function_or_macro_modules,
143
          called_fn: called_fn,
144
          calling_fn: calling_fn,
145
          in_function_def: name,
146
          function_call_tree: tree
147
        } = acc
148
      ) do
149
    modules = Map.merge(modules_in_scope, in_function_or_macro_modules)
247,522✔
150

151
    acc = track_all_functions(acc, node)
247,522✔
152

153
    match_called_fn? =
247,522✔
154
      matching_function_call?(node, called_fn, modules, module) and
246,325✔
155
        not in_function?({module, name}, called_fn)
1,197✔
156

157
    match_calling_fn? =
247,522✔
158
      if calling_fn do
159
        in_function?({module, name}, calling_fn)
59,214✔
160
      else
161
        # in any calling function or macro? (ignoring module-level calls)
162
        acc.in_function_def || acc.in_macro_def
188,308✔
163
      end
164

165
    cond do
247,522✔
166
      match_called_fn? and match_calling_fn? ->
247,522✔
167
        {node, %{acc | found_called: true}}
168

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

172
      true ->
246,441✔
173
        {node, acc}
174
    end
175
  end
176

177
  @doc """
178
  compare a node to the function_signature, looking for a match for a called function
179
  """
180
  @spec matching_function_call?(
181
          Macro.t(),
182
          AssertCall.function_signature(),
183
          %{[atom] => [atom] | keyword()},
184
          module()
185
        ) :: boolean()
186

187
  # For erlang libraries: :math._ or :math.pow
188
  def matching_function_call?(
70✔
189
        {{:., _, [module_path, name]}, _, _args},
190
        {module_path, search_name},
191
        _modules,
192
        _in_module
193
      )
194
      when search_name in [:_, name] do
195
    true
196
  end
197

198
  # No module path in search
199
  def matching_function_call?({_, _, args} = function, {nil, search_name}, modules, in_module)
200
      when is_list(args) do
201
    case function do
13,641✔
202
      # function call with captured notation
203
      {:/, _, [{^search_name, _, atom}, arity]} when is_atom(atom) and is_integer(arity) ->
8✔
204
        true
205

206
      # with parentheses
207
      {^search_name, _, _args} ->
519✔
208
        true
209

210
      # Kernel functions
211
      {{:., _, [{:__aliases__, _, [:Kernel]}, ^search_name]}, _, _args} ->
4✔
212
        true
213

214
      # local calls that unnecessarily reference the module by name
215
      {{:., _, [{:__aliases__, _, _}, ^search_name]}, _, _args} ->
216
        matching_function_call?(function, {in_module, search_name}, modules, in_module)
21✔
217

218
      # local calls that unnecessarily reference the module via __MODULE__
219
      {{:., meta1, [{:__MODULE__, _, _}, name]}, meta2, args} ->
220
        matching_function_call?(
28✔
221
          {{:., meta1, [{:__aliases__, [], in_module}, name]}, meta2, args},
222
          {in_module, search_name},
223
          modules,
224
          in_module
225
        )
226

227
      _ ->
13,061✔
228
        false
229
    end
230
  end
231

232
  # Module path in AST
233
  def matching_function_call?(
234
        {{:., _, [{:__aliases__, _, [head | tail] = ast_path}, name]}, _, _args},
235
        {module_path, search_name},
236
        modules,
237
        _in_module
238
      )
239
      when search_name in [:_, name] do
240
    # Searching for A.B.C.function()
241
    cond do
1,865✔
242
      # Same path: A.B.C.function()
243
      ast_path == module_path -> true
403✔
244
      # aliased: alias A.B ; B.C.function()
245
      List.wrap(modules[[head]]) ++ tail == List.wrap(module_path) -> true
1,462✔
246
      # imported: import A.B ; C.function()
247
      Map.has_key?(modules, List.wrap(module_path) -- ast_path) -> true
1,407✔
248
      true -> false
1,398✔
249
    end
250
  end
251

252
  # No module path in AST
253
  def matching_function_call?(function, {module_path, search_name}, modules, _in_module) do
254
    {name, arity} =
231,995✔
255
      case function do
256
        {:&, _, [{:/, _, [{name, _, name_args}, arity]}]}
257
        when is_atom(name) and is_atom(name_args) and is_integer(arity) and
258
               search_name in [name, :_] ->
54✔
259
          {name, arity}
260

261
        {name, meta, args} when is_atom(name) and is_list(args) and search_name in [name, :_] ->
25,469✔
262
          {name, length(args) + Keyword.get(meta, :extra_arg, 0)}
263

264
        _ ->
206,472✔
265
          {nil, 0}
266
      end
267

268
    function_imported?(module_path, name, arity, modules)
231,995✔
269
  end
270

271
  def matching_function_call?(_, _, _, _), do: false
×
272

273
  defp function_imported?(module, name, arity, modules) do
274
    case modules[List.wrap(module)] do
231,995✔
275
      nil ->
178,252✔
276
        false
277

278
      imported ->
279
        {name, arity} in imported or {String.to_atom("MACRO-#{name}"), arity + 1} in imported
53,743✔
280
    end
281
  end
282

283
  @doc """
284
  node is a module definition
285
  """
286
  def module_def?({:defmodule, _, [{:__aliases__, _, _}, [do: _]]}), do: true
287
  def module_def?(_node), do: false
788,102✔
288

289
  @doc """
290
  get the name of a module from a module definition node
291
  """
292
  def extract_module_name({:defmodule, _, [{:__aliases__, _, name}, [do: _]]}),
293
    do: name
5,598✔
294

295
  @doc """
296
  node is a function definition
297
  """
298
  def function_def?({def_type, _, [_, [{:do, _} | _]]}) when def_type in ~w[def defp]a do
22,688✔
299
    true
300
  end
301

302
  def function_def?(_node), do: false
510,276✔
303

304
  @doc """
305
  node is a macro definition
306
  """
307
  def macro_def?({def_type, _, [_, [{:do, _} | _]]}) when def_type in ~w[defmacro defmacrop]a do
70✔
308
    true
309
  end
310

311
  def macro_def?(_node), do: false
255,068✔
312

313
  @doc """
314
  get the name of a function or macro from a definition node
315
  """
316
  def extract_function_or_macro_name(
317
        {def_type, _, [{:when, _, [{name, _, _} | _]}, [{:do, _} | _]]}
318
      )
319
      when is_atom(name) and def_type in ~w[def defp defmacro defmacrop]a,
320
      do: name
699✔
321

322
  def extract_function_or_macro_name({def_type, _, [{name, _, _}, [{:do, _} | _]]})
323
      when is_atom(name) and def_type in ~w[def defp defmacro defmacrop]a,
324
      do: name
10,715✔
325

326
  @doc """
327
  compare the name of the function to the function signature, if they match return true
328
  """
329
  def in_function?({module, name}, {module, name}), do: true
14,544✔
330
  def in_function?({_, name}, {nil, name}), do: true
116✔
331
  def in_function?(_, _), do: false
45,751✔
332

333
  defp annotate_piped_functions({:|>, pipe_meta, [function, in_pipe_function]}) do
334
    case in_pipe_function do
3,388✔
335
      {name, meta, atom} when is_atom(atom) ->
336
        {:|>, pipe_meta, [function, {name, Keyword.put(meta, :extra_arg, 1), []}]}
172✔
337

338
      {name, meta, args} ->
339
        {:|>, pipe_meta, [function, {name, Keyword.put(meta, :extra_arg, 1), args}]}
3,216✔
340
    end
341
  end
342

343
  defp annotate_piped_functions(node), do: node
268,692✔
344

345
  defp annotate_defdelegate({:defdelegate, meta, [{name, name_meta, name_args}, delegate_opts]})
346
       when is_list(delegate_opts) do
347
    module = Keyword.get(delegate_opts, :to)
150✔
348
    called_name = Keyword.get(delegate_opts, :as) || name
150✔
349

350
    {:def, meta,
150✔
351
     [{name, name_meta, name_args}, [do: {{:., [], [module, called_name]}, [], name_args}]]}
352
  end
353

354
  defp annotate_defdelegate(node), do: node
271,930✔
355

356
  # track_imports
357

358
  # import an Erlang module without options
359
  defp track_imports(acc, {:import, _, [module]}) when is_atom(module) do
360
    paths = [{[module], module.module_info(:exports)}]
48✔
361
    track_modules(acc, paths)
48✔
362
  end
363

364
  # import an Erlang module with only: :functions
365
  defp track_imports(acc, {:import, _, [module, [only: :functions]]}) when is_atom(module) do
366
    paths = [{[module], module.module_info(:exports)}]
11✔
367
    track_modules(acc, paths)
11✔
368
  end
369

370
  # import Elixir module without options
371
  defp track_imports(acc, {:import, _, [module_paths]}) do
372
    paths =
313✔
373
      get_import_paths(module_paths)
374
      |> Enum.map(fn path ->
375
        module = Module.concat(path)
341✔
376

377
        case Code.ensure_loaded(module) do
341✔
378
          {:module, _} -> {path, module.__info__(:functions) ++ module.__info__(:macros)}
299✔
379
          {:error, _} -> {path, []}
42✔
380
        end
381
      end)
382

383
    track_modules(acc, paths)
313✔
384
  end
385

386
  # import module with :only and a list of functions
387
  defp track_imports(acc, {:import, _, [module_path, [only: only]]}) when is_list(only) do
388
    paths =
236✔
389
      get_import_paths(module_path)
390
      |> Enum.map(fn path -> {path, only} end)
236✔
391

392
    track_modules(acc, paths)
236✔
393
  end
394

395
  # import with :except
396
  defp track_imports(acc, {:import, _, [module_path, [except: except]]}) do
397
    %{modules_in_scope: modules} = track_imports(acc, {:import, [], [module_path]})
84✔
398

399
    paths = Enum.map(modules, fn {path, functions} -> {path, functions -- except} end)
84✔
400

401
    track_modules(acc, paths)
84✔
402
  end
403

404
  # import Elixir module with only: :functions or only: :macros
405
  defp track_imports(acc, {:import, _, [module_path, [only: functions_or_macros]]}) do
406
    paths =
35✔
407
      get_import_paths(module_path)
408
      |> Enum.map(fn path ->
409
        module = Module.concat(path)
35✔
410

411
        case Code.ensure_loaded(module) do
35✔
412
          {:module, _} -> {path, module.__info__(functions_or_macros)}
21✔
413
          {:error, _} -> {path, []}
14✔
414
        end
415
      end)
416

417
    track_modules(acc, paths)
35✔
418
  end
419

420
  defp track_imports(acc, _) do
421
    acc
271,437✔
422
  end
423

424
  # get_import_paths
425
  defp get_import_paths({:__aliases__, _, path}) do
583✔
426
    [path]
427
  end
428

429
  defp get_import_paths({{:., _, [root, :{}]}, _, branches}) do
430
    [root_path] = get_import_paths(root)
21✔
431

432
    for branch <- branches,
21✔
433
        path <- get_import_paths(branch) do
49✔
434
      root_path ++ path
435
    end
436
  end
437

438
  defp get_import_paths(path) when is_atom(path) do
50✔
439
    [[path]]
440
  end
441

442
  # track_aliases
443
  defp track_aliases(acc, {:alias, _, [module_path]}) do
444
    paths = get_alias_paths(module_path)
121✔
445
    track_modules(acc, paths)
121✔
446
  end
447

448
  defp track_aliases(acc, {:alias, _, [module_path, [as: {:__aliases__, _, [alias]}]]}) do
449
    paths = get_alias_paths(module_path) |> Enum.map(fn {_, path} -> {[alias], path} end)
288✔
450
    track_modules(acc, paths)
288✔
451
  end
452

453
  defp track_aliases(acc, _) do
454
    acc
271,671✔
455
  end
456

457
  # get_alias_paths
458
  defp get_alias_paths({:__aliases__, _, path}) do
427✔
459
    [{[List.last(path)], path}]
460
  end
461

462
  defp get_alias_paths({{:., _, [root, :{}]}, _, branches}) do
463
    [{_, root_path}] = get_alias_paths(root)
28✔
464

465
    for branch <- branches,
28✔
466
        {last, full_path} <- get_alias_paths(branch) do
84✔
467
      {last, root_path ++ full_path}
468
    end
469
  end
470

471
  defp get_alias_paths(path) when is_atom(path) do
66✔
472
    [{[path], [path]}]
473
  end
474

475
  # track modules
476
  defp track_modules(acc, module_paths) do
477
    Enum.reduce(module_paths, acc, fn {alias, full_path}, acc ->
1,136✔
478
      if acc.in_function_def || acc.in_macro_def,
1,320✔
479
        do: %{
480
          acc
481
          | in_function_or_macro_modules:
159✔
482
              Map.put(acc.in_function_or_macro_modules, alias, full_path)
159✔
483
        },
484
        else: %{acc | modules_in_scope: Map.put(acc.modules_in_scope, alias, full_path)}
1,161✔
485
    end)
486
  end
487

488
  # track all called functions
489
  def track_all_functions(
490
        %{
491
          function_call_tree: tree,
492
          in_module: module,
493
          in_function_def: name,
494
          modules_in_scope: modules_in_scope,
495
          in_function_or_macro_modules: in_function_or_macro_modules
496
        } = acc,
497
        {_, _, args} = function
498
      )
499
      when not is_nil(name) and is_list(args) do
500
    module_aliases = Map.merge(modules_in_scope, in_function_or_macro_modules)
76,743✔
501

502
    {module_called, name_called} =
76,743✔
503
      case function do
504
        {:., _, [{:__MODULE__, _, _}, fn_name]} ->
92✔
505
          {module, fn_name}
506

507
        {:., _, [{:__aliases__, _, fn_module}, fn_name]} ->
6,933✔
508
          {fn_module, fn_name}
509

510
        {:/, _, [{fn_name, _, atom}, arity]} when is_atom(atom) and is_integer(arity) ->
260✔
511
          {module, fn_name}
512

513
        {fn_name, _, _} ->
69,458✔
514
          {module, fn_name}
515
      end
516

517
    called = {Map.get(module_aliases, module_called, module_called), name_called}
76,743✔
518

519
    %{acc | function_call_tree: Map.update(tree, {module, name}, [called], &[called | &1])}
76,743✔
520
  end
521

522
  def track_all_functions(acc, _node), do: acc
170,779✔
523

524
  # Check if a function was called through helper functions
525
  def indirect_call?(%{called_fn: called_fn, calling_fn: calling_fn, function_call_tree: tree}) do
526
    cond do
3,979✔
527
      # calling_fn wasn't defined in the code, or was searched already
528
      is_nil(tree[calling_fn]) ->
3,477✔
529
        false
530

531
      # calling_fn directly called called_fn
532
      called_fn in tree[calling_fn] ->
502✔
533
        true
534

535
      # calling_fn didn't call called_fn, recursively check if other called functions did
536
      true ->
395✔
537
        Enum.any?(
395✔
538
          tree[calling_fn],
539
          &indirect_call?(%{
3,075✔
540
            called_fn: called_fn,
541
            calling_fn: &1,
542
            # Remove tree branch since we know it doesn't call called_fn
543
            function_call_tree: Map.delete(tree, calling_fn)
544
          })
545
        )
546
    end
547
  end
548
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc