config_file.ex (13338B)
1 defmodule Credo.ConfigFile do 2 @moduledoc """ 3 `ConfigFile` structs represent all loaded and merged config files in a run. 4 """ 5 6 @config_filename ".credo.exs" 7 @default_config_name "default" 8 @origin_user :file 9 10 @default_glob "**/*.{ex,exs}" 11 @default_files_included [@default_glob] 12 @default_files_excluded [] 13 @default_parse_timeout 5000 14 @default_strict false 15 @default_color true 16 @valid_checks_keys ~w(enabled disabled extra)a 17 18 alias Credo.Execution 19 20 defstruct origin: nil, 21 filename: nil, 22 config_name_found?: nil, 23 files: nil, 24 color: true, 25 checks: nil, 26 requires: [], 27 plugins: [], 28 parse_timeout: nil, 29 strict: false 30 31 @doc """ 32 Returns Execution struct representing a consolidated Execution for all `.credo.exs` 33 files in `relevant_directories/1` merged into the default configuration. 34 35 - `config_name`: name of the configuration to load 36 - `safe`: if +true+, the config files are loaded using static analysis rather 37 than `Code.eval_string/1` 38 """ 39 def read_or_default(exec, dir, config_name \\ nil, safe \\ false) do 40 dir 41 |> relevant_config_files 42 |> combine_configs(exec, dir, config_name, safe) 43 end 44 45 @doc """ 46 Returns the provided config_file merged into the default configuration. 47 48 - `config_file`: full path to the custom configuration file 49 - `config_name`: name of the configuration to load 50 - `safe`: if +true+, the config files are loaded using static analysis rather 51 than `Code.eval_string/1` 52 """ 53 def read_from_file_path(exec, dir, config_filename, config_name \\ nil, safe \\ false) do 54 if File.exists?(config_filename) do 55 combine_configs([config_filename], exec, dir, config_name, safe) 56 else 57 {:error, {:notfound, "Given config file does not exist: #{config_filename}"}} 58 end 59 end 60 61 defp combine_configs(files, exec, dir, config_name, safe) do 62 config_files = 63 files 64 |> Enum.filter(&File.exists?/1) 65 |> Enum.map(&{@origin_user, &1, File.read!(&1)}) 66 67 exec = Enum.reduce(config_files, exec, &Execution.append_config_file(&2, &1)) 68 69 Execution.get_config_files(exec) 70 |> Enum.map(&from_exs(exec, dir, config_name || @default_config_name, &1, safe)) 71 |> ensure_any_config_found(config_name) 72 |> merge() 73 |> map_ok_files() 74 |> ensure_values_present() 75 end 76 77 defp ensure_any_config_found(list, config_name) do 78 config_not_found = 79 Enum.all?(list, fn 80 {:ok, %__MODULE__{config_name_found?: false}} -> true 81 _ -> false 82 end) 83 84 if config_not_found do 85 filenames_as_list = 86 list 87 |> Enum.map(fn 88 {:ok, %__MODULE__{origin: :file, filename: filename}} -> " * #{filename}\n" 89 _ -> nil 90 end) 91 |> Enum.reject(&is_nil/1) 92 93 message = 94 case filenames_as_list do 95 [] -> 96 "Given config name #{inspect(config_name)} does not exist." 97 98 filenames_as_list -> 99 """ 100 Given config name #{inspect(config_name)} does not exist in any config file: 101 102 #{filenames_as_list} 103 """ 104 end 105 |> String.trim() 106 107 {:error, {:config_name_not_found, message}} 108 else 109 list 110 end 111 end 112 113 defp relevant_config_files(dir) do 114 dir 115 |> relevant_directories 116 |> add_config_files 117 end 118 119 @doc """ 120 Returns all parent directories of the given `dir` as well as each `./config` 121 sub-directory. 122 """ 123 def relevant_directories(dir) do 124 dir 125 |> Path.expand() 126 |> Path.split() 127 |> Enum.reverse() 128 |> get_dir_paths 129 |> add_config_dirs 130 end 131 132 defp ensure_values_present({:ok, config}) do 133 # TODO: config.check_for_updates is deprecated, but should not lead to a validation error 134 config = %__MODULE__{ 135 origin: config.origin, 136 filename: config.filename, 137 config_name_found?: config.config_name_found?, 138 checks: config.checks, 139 color: merge_boolean(@default_color, config.color), 140 files: %{ 141 included: merge_files_default(@default_files_included, config.files.included), 142 excluded: merge_files_default(@default_files_excluded, config.files.excluded) 143 }, 144 parse_timeout: merge_parse_timeout(@default_parse_timeout, config.parse_timeout), 145 plugins: config.plugins || [], 146 requires: config.requires || [], 147 strict: merge_boolean(@default_strict, config.strict) 148 } 149 150 {:ok, config} 151 end 152 153 defp ensure_values_present(error), do: error 154 155 defp get_dir_paths(dirs), do: do_get_dir_paths(dirs, []) 156 157 defp do_get_dir_paths(dirs, acc) when length(dirs) < 2, do: acc 158 159 defp do_get_dir_paths([dir | tail], acc) do 160 expanded_path = 161 tail 162 |> Enum.reverse() 163 |> Path.join() 164 |> Path.join(dir) 165 166 do_get_dir_paths(tail, [expanded_path | acc]) 167 end 168 169 defp add_config_dirs(paths) do 170 Enum.flat_map(paths, fn path -> [path, Path.join(path, "config")] end) 171 end 172 173 defp add_config_files(paths) do 174 for path <- paths, do: Path.join(path, @config_filename) 175 end 176 177 defp from_exs(exec, dir, config_name, {origin, filename, exs_string}, safe) do 178 case Credo.ExsLoader.parse(exs_string, filename, exec, safe) do 179 {:ok, data} -> 180 from_data(data, dir, filename, origin, config_name) 181 182 {:error, {line_no, description, trigger}} -> 183 {:error, {:badconfig, filename, line_no, description, trigger}} 184 185 {:error, reason} -> 186 {:error, {:badconfig, filename, reason}} 187 end 188 end 189 190 defp from_data(data, dir, filename, origin, config_name) do 191 data = 192 data[:configs] 193 |> List.wrap() 194 |> Enum.find(&(&1[:name] == config_name)) 195 196 config_name_found? = not is_nil(data) 197 198 config_file = %__MODULE__{ 199 origin: origin, 200 filename: filename, 201 config_name_found?: config_name_found?, 202 checks: checks_from_data(data, filename), 203 color: data[:color], 204 files: files_from_data(data, dir), 205 parse_timeout: data[:parse_timeout], 206 plugins: data[:plugins] || [], 207 requires: data[:requires] || [], 208 strict: data[:strict] 209 } 210 211 {:ok, config_file} 212 end 213 214 defp files_from_data(data, dir) do 215 case data[:files] do 216 nil -> 217 nil 218 219 %{} = files -> 220 included_files = files[:included] || dir 221 222 included_dir = 223 included_files 224 |> List.wrap() 225 |> Enum.map(&join_default_files_if_directory/1) 226 227 %{ 228 included: included_dir, 229 excluded: files[:excluded] || @default_files_excluded 230 } 231 end 232 end 233 234 defp checks_from_data(data, filename) do 235 case data[:checks] do 236 checks when is_list(checks) -> 237 checks 238 239 %{} = checks -> 240 do_warn_if_check_params_invalid(checks, filename) 241 242 checks 243 244 _ -> 245 [] 246 end 247 end 248 249 defp do_warn_if_check_params_invalid(checks, filename) do 250 Enum.each(checks, fn 251 {checks_key, _name} when checks_key not in @valid_checks_keys -> 252 candidate = find_best_match(@valid_checks_keys, checks_key) 253 warning = warning_message_for(filename, checks_key, candidate) 254 255 Credo.CLI.Output.UI.warn([:red, warning]) 256 257 _ -> 258 nil 259 end) 260 end 261 262 defp warning_message_for(filename, checks_key, candidate) do 263 if candidate do 264 "** (config) #{filename}: config field `:checks` contains unknown key `#{inspect(checks_key)}`. Did you mean `#{inspect(candidate)}`?" 265 else 266 "** (config) #{filename}: config field `:checks` contains unknown key `#{inspect(checks_key)}`." 267 end 268 end 269 270 defp find_best_match(candidates, given, threshold \\ 0.8) do 271 given_string = to_string(given) 272 273 {jaro_distance, candidate} = 274 candidates 275 |> Enum.map(fn candidate_name -> 276 distance = String.jaro_distance(given_string, to_string(candidate_name)) 277 {distance, candidate_name} 278 end) 279 |> Enum.sort() 280 |> List.last() 281 282 if jaro_distance > threshold do 283 candidate 284 end 285 end 286 287 @doc """ 288 Merges the given structs from left to right, meaning that later entries 289 overwrites earlier ones. 290 291 merge(base, other) 292 293 Any options in `other` will overwrite those in `base`. 294 295 The `files:` field is merged, meaning that you can define `included` and/or 296 `excluded` and only override the given one. 297 298 The `checks:` field is merged. 299 """ 300 def merge(list) when is_list(list) do 301 base = List.first(list) 302 tail = List.delete_at(list, 0) 303 304 merge(tail, base) 305 end 306 307 # bubble up errors from parsing the config so we can deal with them at the top-level 308 def merge({:error, _} = error), do: error 309 310 def merge([], config), do: config 311 312 def merge([other | tail], base) do 313 new_base = merge(base, other) 314 315 merge(tail, new_base) 316 end 317 318 # bubble up errors from parsing the config so we can deal with them at the top-level 319 def merge({:error, _} = a, _), do: a 320 def merge(_, {:error, _} = a), do: a 321 322 def merge({:ok, base}, {:ok, other}) do 323 config_file = %__MODULE__{ 324 checks: merge_checks(base, other), 325 color: merge_boolean(base.color, other.color), 326 files: merge_files(base, other), 327 parse_timeout: merge_parse_timeout(base.parse_timeout, other.parse_timeout), 328 plugins: base.plugins ++ other.plugins, 329 requires: base.requires ++ other.requires, 330 strict: merge_boolean(base.strict, other.strict) 331 } 332 333 {:ok, config_file} 334 end 335 336 defp merge_boolean(base, other) 337 338 defp merge_boolean(_base, true), do: true 339 defp merge_boolean(_base, false), do: false 340 defp merge_boolean(base, _), do: base 341 342 defp merge_files_default(_base, [_head | _tail] = non_empty_list), do: non_empty_list 343 defp merge_files_default(base, _), do: base 344 345 defp merge_parse_timeout(_base, timeout) when is_integer(timeout), do: timeout 346 defp merge_parse_timeout(base, _), do: base 347 348 def merge_checks(%__MODULE__{checks: checks_list_base}, %__MODULE__{checks: checks_list_other}) 349 when is_list(checks_list_base) and is_list(checks_list_other) do 350 base = %__MODULE__{checks: %{enabled: checks_list_base}} 351 other = %__MODULE__{checks: %{extra: checks_list_other}} 352 353 merge_checks(base, other) 354 end 355 356 def merge_checks(%__MODULE__{checks: checks_base}, %__MODULE__{ 357 checks: %{extra: _} = checks_map_other 358 }) 359 when is_list(checks_base) do 360 base = %__MODULE__{checks: %{enabled: checks_base}} 361 other = %__MODULE__{checks: checks_map_other} 362 363 merge_checks(base, other) 364 end 365 366 def merge_checks(%__MODULE__{checks: %{enabled: checks_list_base}}, %__MODULE__{ 367 checks: checks_other 368 }) 369 when is_list(checks_list_base) and is_list(checks_other) do 370 base = %__MODULE__{checks: %{enabled: checks_list_base}} 371 other = %__MODULE__{checks: %{extra: checks_other}} 372 373 merge_checks(base, other) 374 end 375 376 def merge_checks(%__MODULE__{checks: _checks_base}, %__MODULE__{ 377 checks: %{enabled: checks_other_enabled} = checks_other 378 }) 379 when is_list(checks_other_enabled) do 380 disabled = disable_check_tuples(checks_other[:disabled]) 381 382 %{ 383 enabled: checks_other_enabled |> normalize_check_tuples() |> Keyword.merge(disabled), 384 disabled: checks_other[:disabled] || [] 385 } 386 end 387 388 def merge_checks(%__MODULE__{checks: %{enabled: checks_base}}, %__MODULE__{ 389 checks: %{} = checks_other 390 }) 391 when is_list(checks_base) do 392 base = normalize_check_tuples(checks_base) 393 other = normalize_check_tuples(checks_other[:extra]) 394 disabled = disable_check_tuples(checks_other[:disabled]) 395 396 %{ 397 enabled: base |> Keyword.merge(other) |> Keyword.merge(disabled), 398 disabled: checks_other[:disabled] || [] 399 } 400 end 401 402 # this def catches all the cases where no valid key was found in `checks_map_other` 403 def merge_checks(%__MODULE__{checks: %{enabled: checks_base}}, %__MODULE__{ 404 checks: %{} 405 }) 406 when is_list(checks_base) do 407 base = %__MODULE__{checks: %{enabled: checks_base}} 408 other = %__MODULE__{checks: []} 409 410 merge_checks(base, other) 411 end 412 413 # 414 415 def merge_files(%__MODULE__{files: files_base}, %__MODULE__{files: files_other}) do 416 %{ 417 included: files_other[:included] || files_base[:included], 418 excluded: files_other[:excluded] || files_base[:excluded] 419 } 420 end 421 422 defp normalize_check_tuples(nil), do: [] 423 424 defp normalize_check_tuples(list) when is_list(list) do 425 Enum.map(list, &normalize_check_tuple/1) 426 end 427 428 defp normalize_check_tuple({name}), do: {name, []} 429 defp normalize_check_tuple(tuple), do: tuple 430 431 defp disable_check_tuples(nil), do: [] 432 433 defp disable_check_tuples(list) when is_list(list) do 434 Enum.map(list, &disable_check_tuple/1) 435 end 436 437 defp disable_check_tuple({name}), do: {name, false} 438 defp disable_check_tuple({name, _params}), do: {name, false} 439 440 defp join_default_files_if_directory(dir) do 441 if File.dir?(dir) do 442 Path.join(dir, @default_files_included) 443 else 444 dir 445 end 446 end 447 448 defp map_ok_files({:error, _} = error) do 449 error 450 end 451 452 defp map_ok_files({:ok, %__MODULE__{files: files} = config}) do 453 files = %{ 454 included: 455 files[:included] 456 |> List.wrap() 457 |> Enum.uniq(), 458 excluded: 459 files[:excluded] 460 |> List.wrap() 461 |> Enum.uniq() 462 } 463 464 {:ok, %__MODULE__{config | files: files}} 465 end 466 end