collector.ex (6110B)
1 defmodule Credo.Check.Consistency.Collector do 2 @moduledoc """ 3 A behavior for modules that walk through source files and 4 identify consistency issues. 5 6 When defining a consistency check, you would typically use 7 this structure for the main module, responsible 8 for formatting issue messages: 9 10 defmodule Credo.Check.Consistency.SomeCheck do 11 use Credo.Check, run_on_all: true 12 13 @collector Credo.Check.Consistency.SomeCheck.Collector 14 15 def run(source_files, exec, params) when is_list(source_files) do 16 issue_formatter = &issues_for/3 17 18 @collector.find_and_append_issues(source_files, exec, params, issue_formatter) 19 end 20 21 defp issues_for(expected, source_file, params) do 22 issue_meta = IssueMeta.for(source_file, params) 23 issue_locations = 24 @collector.find_locations_not_matching(expected, source_file) 25 26 Enum.map(issue_locations, fn(location) -> 27 format_issue issue_meta, message: ... # write an issue message 28 end) 29 end 30 31 The actual analysis would be performed by another module 32 implementing the `Credo.Check.Consistency.Collector` behavior: 33 34 defmodule Credo.Check.Consistency.SomeCheck.Collector do 35 use Credo.Check.Consistency.Collector 36 37 def collect_matches(source_file, params) do 38 # ... 39 end 40 41 def find_locations_not_matching(expected, source_file) do 42 # ... 43 end 44 end 45 46 Read further for more information on `collect_matches/2`, 47 `find_locations_not_matching/2`, and `issue_formatter`. 48 """ 49 50 alias Credo.Execution.ExecutionIssues 51 alias Credo.Issue 52 alias Credo.SourceFile 53 54 @doc """ 55 When you call `@collector.find_and_append_issues/4` inside the check module, 56 the collector first counts the occurrences of different matches 57 (e.g. :with_space and :without_space for a space around operators check) 58 per each source file. 59 60 `collect_matches/2` produces a map of matches as keys and their frequencies 61 as values (e.g. %{with_space: 50, without_space: 40}). 62 63 The maps for individual source files are then merged, producing a map 64 that reflects frequency trends for the whole codebase. 65 """ 66 @callback collect_matches( 67 source_file :: SourceFile.t(), 68 params :: Keyword.t() 69 ) :: %{ 70 term => non_neg_integer 71 } 72 73 # Once the most frequent match is identified, the `Collector` looks up 74 # source files that have other matches (e.g. both :with_space 75 # and :without_space or just :without_space when :with_space is the 76 # most frequent) and calls the `issue_formatter` function on them. 77 # 78 # An issue formatter produces a list of `Credo.Issue` structs 79 # from the most frequent (expected) match, a source file 80 # containing other matches, and check params 81 # (the latter two are required to build an IssueMeta). 82 @type issue_formatter :: (term, SourceFile.t(), Keyword.t() -> [Issue.t()]) 83 84 @doc """ 85 `issue_formatter` may call the `@collector.find_locations_not_matching/2` 86 function to obtain additional metadata for each occurrence of 87 an unexpected match in a given file. 88 89 An example implementation that returns a list of line numbers on 90 which unexpected occurrences were found: 91 92 def find_locations_not_matching(expected, source_file) do 93 traverse(source_file, fn(match, line_no, acc) -> 94 if match != expected do 95 acc ++ [line_no] 96 else 97 acc 98 end 99 end) 100 end 101 102 defp traverse(source_file, fun), do: ... 103 """ 104 @callback find_locations_not_matching( 105 expected :: term, 106 source_file :: SourceFile.t() 107 ) :: list(term) 108 109 @optional_callbacks find_locations_not_matching: 2 110 111 defmacro __using__(_opts) do 112 quote do 113 @behaviour Credo.Check.Consistency.Collector 114 115 alias Credo.Check.Consistency.Collector 116 alias Credo.Execution 117 alias Credo.Issue 118 alias Credo.SourceFile 119 120 @spec find_and_append_issues( 121 [SourceFile.t()], 122 Execution.t(), 123 Keyword.t(), 124 Collector.issue_formatter() 125 ) :: atom 126 def find_and_append_issues(source_files, exec, params, issue_formatter) 127 when is_list(source_files) and is_function(issue_formatter) do 128 source_files 129 |> Collector.find_issues(__MODULE__, params, issue_formatter) 130 |> Enum.each(&Collector.append_issue_via_issue_service(&1, exec)) 131 132 :ok 133 end 134 end 135 end 136 137 def find_issues(source_files, collector, params, issue_formatter) do 138 frequencies_per_source_file = 139 source_files 140 |> Enum.map(&Task.async(fn -> {&1, collector.collect_matches(&1, params)} end)) 141 |> Enum.map(&Task.await(&1, :infinity)) 142 143 frequencies = total_frequencies(frequencies_per_source_file) 144 145 if map_size(frequencies) > 0 do 146 most_frequent_match = 147 case params[:force] do 148 nil -> 149 {most_frequent_match, _frequency} = Enum.max_by(frequencies, &elem(&1, 1)) 150 151 most_frequent_match 152 153 value -> 154 value 155 end 156 157 result = 158 frequencies_per_source_file 159 |> source_files_with_issues(most_frequent_match) 160 |> Enum.map(&Task.async(fn -> issue_formatter.(most_frequent_match, &1, params) end)) 161 |> Enum.flat_map(&Task.await(&1, :infinity)) 162 163 result 164 else 165 [] 166 end 167 end 168 169 def append_issue_via_issue_service(%Issue{} = issue, exec) do 170 ExecutionIssues.append(exec, issue) 171 end 172 173 defp source_files_with_issues(frequencies_per_file, most_frequent_match) do 174 Enum.reduce(frequencies_per_file, [], fn {filename, stats}, acc -> 175 unexpected_matches = Map.keys(stats) -- [most_frequent_match] 176 177 if unexpected_matches != [] do 178 [filename | acc] 179 else 180 acc 181 end 182 end) 183 end 184 185 defp total_frequencies(frequencies_per_file) do 186 Enum.reduce(frequencies_per_file, %{}, fn {_, file_stats}, stats -> 187 Map.merge(stats, file_stats, fn _k, f1, f2 -> f1 + f2 end) 188 end) 189 end 190 end