• 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

93.75
/test/support/exercise_test_case.ex
1
defmodule ElixirAnalyzer.ExerciseTestCase do
2
  @moduledoc """
3
    A test case for exercise test module tests.
4

5
    ## Usage
6

7
    ```
8
    use ElixirAnalyzer.ExerciseTestCase, exercise_test_module: ElixirAnalyzer.ExerciseTest.ExerciseName
9
    ```
10
  """
11

12
  use ExUnit.CaseTemplate
13
  alias ElixirAnalyzer.Source
14

15
  @dialyzer no_match: {:assert_comments, 3}
16
  @exercise_config Application.compile_env(:elixir_analyzer, :exercise_config)
17

18
  using opts do
71✔
19
    quote do
20
      @exercise_test_module unquote(opts)[:exercise_test_module]
21
      @unsorted_comments unquote(opts)[:unsorted_comments]
22
      @source ElixirAnalyzer.ExerciseTestCase.find_source(@exercise_test_module)
23
      require ElixirAnalyzer.ExerciseTestCase
24
      import ElixirAnalyzer.ExerciseTestCase
25
      alias ElixirAnalyzer.Constants
26
    end
27
  end
28

29
  @doc ~S"""
30
    Defines test cases for the exercise test module.
31

32
    ## Usage
33

34
    ```
35
    test_exercise_analysis "missing moduledoc",
36
      comments: [Constants.solution_use_moduledoc()] do
37
      defmodule TwoFer do
38
        @spec two_fer(String.t()) :: String.t()
39
        def two_fer(name \\ "you") when is_binary(name) do
40
          "One for #{name}, one for me."
41
        end
42
      end
43
    end
44
    ```
45

46
    ## Assertions
47

48
    All assertions are optional, but at least one is required.
49

50
    - `:comments` - checks that the comments produced by the analysis and this list have the same elements, ignoring their order.
51
    - `:comments_include` - checks that the comments produced by the analysis include all elements from this list.
52
    - `:comments_exclude` - checks that the comments produced by the analysis include none of the elements from this list.
53

54
    ## Code
55

56
    The code of solutions to be analyzed should be passed in the `do` block directly, without quoting.
57
    Passing a list of code blocks is also supported.
58
  """
59
  defmacro test_exercise_analysis(name, assertions, do: test_cases) do
425✔
60
    alias ElixirAnalyzer.Constants
61

62
    supported_assertions_keys = [:comments, :comments_include, :comments_exclude]
425✔
63
    assertions_keys = Keyword.keys(assertions)
425✔
64
    assertions_key_diff = assertions_keys -- supported_assertions_keys
425✔
65

66
    if assertions_keys == [] do
425✔
67
      supported = Enum.join(supported_assertions_keys, ", ")
1✔
68
      raise "Expected to receive at least one of the supported assertions: #{supported}"
1✔
69
    end
70

71
    if assertions_key_diff != [] do
424✔
72
      raise "Unsupported assertions received: #{Enum.join(assertions_key_diff, ", ")}"
2✔
73
    end
74

75
    test_cases = List.wrap(test_cases)
422✔
76

77
    test_cases
78
    |> Enum.with_index()
79
    |> Enum.map(fn {code, index} ->
422✔
80
      test_name =
1,000✔
81
        case test_cases do
82
          [_, _ | _] -> "#{name} #{index + 1}"
782✔
83
          _ -> name
218✔
84
        end
85

86
      {line, code} =
1,000✔
87
        case code do
88
          {:sigil_S, opts, [{:<<>>, _, [inner_code]}, []]} when is_bitstring(inner_code) ->
44✔
89
            {Keyword.get(opts, :line), inner_code}
90

91
          {_, opts, _} ->
955✔
92
            {Keyword.get(opts, :line), Macro.to_string(code)}
93

94
          code when is_bitstring(code) ->
1✔
95
            {__CALLER__.line, code}
1✔
96

97
          code ->
×
98
            {__CALLER__.line, Macro.to_string(code)}
×
99
        end
100

101
      quote line: line do
1,000✔
102
        test "#{unquote(test_name)}" do
103
          source = %{@source | code_string: unquote(code)}
104

105
          empty_submission = %ElixirAnalyzer.Submission{
106
            source: source,
107
            analysis_module: ""
108
          }
109

110
          result = @exercise_test_module.analyze(empty_submission)
111

112
          comments =
113
            result.comments
114
            |> Enum.map(fn comment_details -> comment_details.comment end)
115
            |> Enum.reject(fn comment ->
116
              # Exclude common comment that's appended to all solutions that have negative comments
117
              # There are too many compiler warnings in tests
118
              comment in [
119
                Constants.general_feedback_request(),
120
                Constants.solution_compiler_warnings()
121
              ]
122
            end)
123

124
          Enum.map(Keyword.keys(unquote(assertions)), fn
125
            :comments ->
126
              assert_comments(comments, :comments, unquote(assertions),
127
                unsorted: @unsorted_comments
128
              )
129

130
            key ->
131
              assert_comments(comments, key, unquote(assertions))
132
          end)
133
        end
134
      end
135
    end)
136
  end
137

138
  def assert_comments(comments, :comments, assertions, unsorted: unsorted) do
139
    expected_comments = assertions[:comments]
437✔
140

141
    cond do
437✔
142
      expected_comments && unsorted ->
437✔
143
        assert comments == expected_comments
1✔
144

145
      expected_comments ->
436✔
146
        assert Enum.sort(comments) == Enum.sort(expected_comments)
436✔
147
    end
148
  end
149

150
  def assert_comments(comments, :comments_include, assertions) do
151
    comments_include = assertions[:comments_include]
359✔
152

153
    if comments_include do
359✔
154
      Enum.each(comments_include, fn comment ->
359✔
155
        assert comment in comments
389✔
156
      end)
157
    end
158
  end
159

160
  def assert_comments(comments, :comments_exclude, assertions) do
161
    comments_exclude = assertions[:comments_exclude]
222✔
162

163
    if comments_exclude do
222✔
164
      Enum.each(comments_exclude, fn comment ->
222✔
165
        refute comment in comments
306✔
166
      end)
167
    end
168
  end
169

170
  # Return as much of the source data as can be found
171

172
  @concept_exercise_path "elixir/exercises/concept"
173
  @practice_exercise_path "elixir/exercises/practice"
174
  @meta_config ".meta/config.json"
175
  def find_source(test_module) do
176
    %Source{submitted_files: []}
177
    |> find_source_slug(test_module)
178
    |> find_source_type
179
    |> find_source_exemploid_files
180
    |> find_source_exemploid
72✔
181
  end
182

183
  defp find_source_slug(source, test_module) do
184
    match_slug = Enum.find(@exercise_config, &match?({_, %{analyzer_module: ^test_module}}, &1))
72✔
185

186
    case match_slug do
72✔
187
      {slug, _test_module} -> %{source | slug: slug}
43✔
188
      _ -> source
29✔
189
    end
190
  end
191

192
  defp find_source_type(%Source{slug: slug} = source) do
193
    concept_ex = File.ls!(@concept_exercise_path)
72✔
194
    practice_ex = File.ls!(@practice_exercise_path)
72✔
195

196
    cond do
72✔
197
      slug in concept_ex -> %{source | exercise_type: :concept}
36✔
198
      slug in practice_ex -> %{source | exercise_type: :practice}
36✔
199
      true -> source
29✔
200
    end
201
  end
202

203
  defp find_source_exemploid_files(%Source{slug: slug, exercise_type: :concept} = source) do
204
    %{"files" => %{"exemplar" => exemploid_files}} =
36✔
205
      [@concept_exercise_path, slug, @meta_config]
206
      |> Path.join()
207
      |> File.read!()
208
      |> Jason.decode!()
209

210
    exemploid_files = Enum.map(exemploid_files, &Path.join([@concept_exercise_path, slug, &1]))
36✔
211
    %{source | exemploid_files: [exemploid_files]}
36✔
212
  end
213

214
  defp find_source_exemploid_files(%Source{slug: slug, exercise_type: :practice} = source) do
215
    %{"files" => %{"example" => exemploid_files}} =
7✔
216
      [@practice_exercise_path, slug, @meta_config]
217
      |> Path.join()
218
      |> File.read!()
219
      |> Jason.decode!()
220

221
    exemploid_files = Enum.map(exemploid_files, &Path.join([@practice_exercise_path, slug, &1]))
7✔
222
    %{source | exemploid_files: exemploid_files}
7✔
223
  end
224

225
  defp find_source_exemploid_files(source), do: source
29✔
226

227
  defp find_source_exemploid(%Source{exemploid_files: exemploid_files} = source)
228
       when is_list(exemploid_files) do
229
    {:ok, exemploid_string} = read_files(exemploid_files)
43✔
230

231
    exemploid_ast =
43✔
232
      exemploid_string
233
      |> Code.format_string!(line_length: 120, force_do_end_blocks: true)
234
      |> IO.iodata_to_binary()
235
      |> Code.string_to_quoted!()
236

237
    %{source | exemploid_string: exemploid_string, exemploid_ast: exemploid_ast}
43✔
238
  end
239

240
  defp find_source_exemploid(source), do: source
29✔
241

242
  defp read_files(paths) do
243
    Enum.reduce_while(
43✔
244
      paths,
245
      {:ok, nil},
246
      fn path, {:ok, code} ->
247
        case File.read(path) do
43✔
248
          {:ok, file} when is_nil(code) -> {:cont, {:ok, file}}
43✔
249
          {:ok, file} -> {:cont, {:ok, code <> "\n" <> file}}
×
250
          {:error, err} -> {:halt, {:error, err}}
×
251
        end
252
      end
253
    )
254
  end
255
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