check.ex (19610B)
1 defmodule Credo.Check do 2 @moduledoc """ 3 `Check` modules represent the checks which are run during Credo's analysis. 4 5 Example: 6 7 defmodule MyCheck do 8 use Credo.Check, category: :warning, base_priority: :high 9 10 def run(%SourceFile{} = source_file, params) do 11 # 12 end 13 end 14 15 The check can be configured by passing the following 16 options to `use Credo.Check`: 17 18 - `:base_priority` Sets the checks's base priority (`:low`, `:normal`, `:high`, `:higher` or `:ignore`). 19 - `:category` Sets the check's category (`:consistency`, `:design`, `:readability`, `:refactor` or `:warning`). 20 - `:elixir_version` Sets the check's version requirement for Elixir (defaults to `>= 0.0.1`). 21 - `:explanations` Sets explanations displayed for the check, e.g. 22 23 ```elixir 24 [ 25 check: "...", 26 params: [ 27 param1: "Your favorite number", 28 param2: "Online/Offline mode" 29 ] 30 ] 31 ``` 32 33 - `:param_defaults` Sets the default values for the check's params (e.g. `[param1: 42, param2: "offline"]`) 34 - `:tags` Sets the tags for this check (list of atoms, e.g. `[:tag1, :tag2]`) 35 36 Please also note that these options to `use Credo.Check` are just a convenience to implement the `Credo.Check` 37 behaviour. You can implement any of these by hand: 38 39 defmodule MyCheck do 40 use Credo.Check 41 42 def category, do: :warning 43 44 def base_priority, do: :high 45 46 def explanations do 47 [ 48 check: "...", 49 params: [ 50 param1: "Your favorite number", 51 param2: "Online/Offline mode" 52 ] 53 ] 54 end 55 56 def param_defaults, do: [param1: 42, param2: "offline"] 57 58 def run(%SourceFile{} = source_file, params) do 59 # 60 end 61 end 62 63 The `run/2` function of a Check module takes two parameters: a source file and a list of parameters for the check. 64 It has to return a list of found issues. 65 """ 66 67 @doc """ 68 Runs the current check on all `source_files` by calling `run_on_source_file/3`. 69 70 If you are developing a check that has to run on all source files, you can overwrite `run_on_all_source_files/3`: 71 72 defmodule MyCheck do 73 use Credo.Check 74 75 def run_on_all_source_files(exec, source_files, params) do 76 issues = 77 source_files 78 |> do_something_crazy() 79 |> do_something_crazier() 80 81 append_issues_and_timings(exec, issues) 82 83 :ok 84 end 85 end 86 87 Check out Credo's checks from the consistency category for examples of these kinds of checks. 88 """ 89 @callback run_on_all_source_files( 90 exec :: Credo.Execution.t(), 91 source_files :: list(Credo.SourceFile.t()), 92 params :: Keyword.t() 93 ) :: :ok 94 95 @doc """ 96 Runs the current check on a single `source_file` and appends the resulting issues to the current `exec`. 97 """ 98 @callback run_on_source_file( 99 exec :: Credo.Execution.t(), 100 source_file :: Credo.SourceFile.t(), 101 params :: Keyword.t() 102 ) :: :ok 103 104 @callback run(source_file :: Credo.SourceFile.t(), params :: Keyword.t()) :: 105 list(Credo.Issue.t()) 106 107 @doc """ 108 Returns the base priority for the check. 109 110 This can be one of `:higher`, `:high`, `:normal`, `:low` or `:ignore` 111 (technically it can also be or an integer, but these are internal representations although that is not recommended). 112 """ 113 @callback base_priority() :: :higher | :high | :normal | :low | :ignore | integer 114 115 @doc """ 116 Returns the category for the check. 117 """ 118 @callback category() :: atom 119 120 @doc """ 121 Returns the required Elixir version for the check. 122 """ 123 @callback elixir_version() :: String.t() 124 125 @doc """ 126 Returns the exit status for the check. 127 """ 128 @callback exit_status() :: integer 129 130 @doc """ 131 Returns the explanations for the check and params as a keyword list. 132 """ 133 @callback explanations() :: Keyword.t() 134 135 @doc """ 136 Returns the default values for the check's params as a keyword list. 137 """ 138 @callback param_defaults() :: Keyword.t() 139 140 # @callback run(source_file :: Credo.SourceFile.t, params :: Keyword.t) :: list() 141 142 @doc """ 143 Returns whether or not this check runs on all source files. 144 """ 145 @callback run_on_all?() :: boolean 146 147 @doc """ 148 Returns the tags for the check. 149 """ 150 @callback tags() :: list(atom) 151 152 @doc false 153 @callback format_issue(issue_meta :: Credo.IssueMeta.t(), opts :: Keyword.t()) :: 154 Credo.Issue.t() 155 156 @base_category_exit_status_map %{ 157 consistency: 1, 158 design: 2, 159 readability: 4, 160 refactor: 8, 161 warning: 16 162 } 163 164 alias Credo.Check 165 alias Credo.Check.Params 166 alias Credo.Code.Scope 167 alias Credo.Issue 168 alias Credo.IssueMeta 169 alias Credo.Priority 170 alias Credo.Service.SourceFileScopes 171 alias Credo.Severity 172 alias Credo.SourceFile 173 174 @valid_use_opts [ 175 :base_priority, 176 :category, 177 :elixir_version, 178 :exit_status, 179 :explanations, 180 :param_defaults, 181 :run_on_all, 182 :tags 183 ] 184 185 @doc false 186 defmacro __using__(opts) do 187 Enum.each(opts, fn 188 {key, _name} when key not in @valid_use_opts -> 189 raise "Could not find key `#{key}` in #{inspect(@valid_use_opts)}" 190 191 _ -> 192 nil 193 end) 194 195 def_base_priority = 196 if opts[:base_priority] do 197 quote do 198 @impl true 199 def base_priority, do: unquote(opts[:base_priority]) 200 end 201 else 202 quote do 203 @impl true 204 def base_priority, do: 0 205 end 206 end 207 208 def_category = 209 if opts[:category] do 210 quote do 211 @impl true 212 def category, do: unquote(category_body(opts[:category])) 213 end 214 else 215 quote do 216 @impl true 217 def category, do: unquote(category_body(nil)) 218 end 219 end 220 221 def_elixir_version = 222 if opts[:elixir_version] do 223 quote do 224 @impl true 225 def elixir_version do 226 unquote(opts[:elixir_version]) 227 end 228 end 229 else 230 quote do 231 @impl true 232 def elixir_version, do: ">= 0.0.1" 233 end 234 end 235 236 def_exit_status = 237 if opts[:exit_status] do 238 quote do 239 @impl true 240 def exit_status do 241 unquote(opts[:exit_status]) 242 end 243 end 244 else 245 quote do 246 @impl true 247 def exit_status, do: Credo.Check.to_exit_status(category()) 248 end 249 end 250 251 def_run_on_all? = 252 if opts[:run_on_all] do 253 quote do 254 @impl true 255 def run_on_all?, do: unquote(opts[:run_on_all] == true) 256 end 257 else 258 quote do 259 @impl true 260 def run_on_all?, do: false 261 end 262 end 263 264 def_param_defaults = 265 if opts[:param_defaults] do 266 quote do 267 @impl true 268 def param_defaults, do: unquote(opts[:param_defaults]) 269 end 270 end 271 272 def_explanations = 273 if opts[:explanations] do 274 quote do 275 @impl true 276 def explanations do 277 unquote(opts[:explanations]) 278 end 279 end 280 end 281 282 def_tags = 283 quote do 284 @impl true 285 def tags do 286 unquote(opts[:tags] || []) 287 end 288 end 289 290 quote do 291 @moduledoc unquote(moduledoc(opts)) 292 @behaviour Credo.Check 293 @before_compile Credo.Check 294 295 @use_deprecated_run_on_all? false 296 297 alias Credo.Check 298 alias Credo.Check.Params 299 alias Credo.CLI.ExitStatus 300 alias Credo.CLI.Output.UI 301 alias Credo.Execution 302 alias Credo.Execution.ExecutionTiming 303 alias Credo.Issue 304 alias Credo.IssueMeta 305 alias Credo.Priority 306 alias Credo.Severity 307 alias Credo.SourceFile 308 309 unquote(def_base_priority) 310 unquote(def_category) 311 unquote(def_elixir_version) 312 unquote(def_exit_status) 313 unquote(def_run_on_all?) 314 unquote(def_param_defaults) 315 unquote(def_explanations) 316 unquote(def_tags) 317 318 @impl true 319 def format_issue(issue_meta, issue_options) do 320 Check.format_issue( 321 issue_meta, 322 issue_options, 323 __MODULE__ 324 ) 325 end 326 327 @doc false 328 @impl true 329 def run_on_all_source_files(exec, source_files, params \\ []) 330 331 @impl true 332 def run_on_all_source_files(exec, source_files, params) do 333 if function_exported?(__MODULE__, :run, 3) do 334 IO.warn( 335 "Defining `run(source_files, exec, params)` for checks that run on all source files is deprecated. " <> 336 "Define `run_on_all_source_files(exec, source_files, params)` instead." 337 ) 338 339 apply(__MODULE__, :run, [source_files, exec, params]) 340 else 341 do_run_on_all_source_files(exec, source_files, params) 342 end 343 end 344 345 defp do_run_on_all_source_files(exec, source_files, params) do 346 source_files 347 |> Enum.map(&Task.async(fn -> run_on_source_file(exec, &1, params) end)) 348 |> Enum.each(&Task.await(&1, :infinity)) 349 350 :ok 351 end 352 353 @doc false 354 @impl true 355 def run_on_source_file(exec, source_file, params \\ []) 356 357 def run_on_source_file(%Execution{debug: true} = exec, source_file, params) do 358 ExecutionTiming.run(&do_run_on_source_file/3, [exec, source_file, params]) 359 |> ExecutionTiming.append(exec, 360 task: exec.current_task, 361 check: __MODULE__, 362 filename: source_file.filename 363 ) 364 end 365 366 def run_on_source_file(exec, source_file, params) do 367 do_run_on_source_file(exec, source_file, params) 368 end 369 370 defp do_run_on_source_file(exec, source_file, params) do 371 issues = 372 try do 373 run(source_file, params) 374 rescue 375 error -> 376 UI.warn("Error while running #{__MODULE__} on #{source_file.filename}") 377 378 if exec.crash_on_error do 379 reraise error, __STACKTRACE__ 380 else 381 [] 382 end 383 end 384 385 append_issues_and_timings(issues, exec) 386 387 :ok 388 end 389 390 @doc false 391 @impl true 392 def run(source_file, params) 393 394 def run(%SourceFile{} = source_file, params) do 395 throw("Implement me") 396 end 397 398 defoverridable Credo.Check 399 400 defp append_issues_and_timings([] = _issues, exec) do 401 exec 402 end 403 404 defp append_issues_and_timings([_ | _] = issues, exec) do 405 Credo.Execution.ExecutionIssues.append(exec, issues) 406 end 407 end 408 end 409 410 @doc false 411 defmacro __before_compile__(env) do 412 quote do 413 unquote(deprecated_def_default_params(env)) 414 unquote(deprecated_def_explanations(env)) 415 416 @doc false 417 def param_names do 418 Keyword.keys(param_defaults()) 419 end 420 421 @deprecated "Use param_defaults/1 instead" 422 @doc false 423 def params_defaults do 424 # deprecated - remove module attribute 425 param_defaults() 426 end 427 428 @deprecated "Use param_names/1 instead" 429 @doc false 430 def params_names do 431 param_names() 432 end 433 434 @deprecated "Use explanations()[:check] instead" 435 @doc false 436 def explanation do 437 # deprecated - remove module attribute 438 explanations()[:check] 439 end 440 441 @deprecated "Use explanations()[:params] instead" 442 @doc false 443 def explanation_for_params do 444 # deprecated - remove module attribute 445 explanations()[:params] 446 end 447 end 448 end 449 450 defp moduledoc(opts) do 451 explanations = opts[:explanations] 452 453 base_priority = opts_to_string(opts[:base_priority]) || 0 454 455 # category = opts_to_string(opts[:category]) || to_string(__MODULE__) 456 457 elixir_version_hint = 458 if opts[:elixir_version] do 459 elixir_version = opts_to_string(opts[:elixir_version]) 460 461 "requires Elixir `#{elixir_version}`" 462 else 463 "works with any version of Elixir" 464 end 465 466 check_doc = opts_to_string(explanations[:check]) 467 params = explanations[:params] |> opts_to_string() |> List.wrap() 468 param_defaults = opts_to_string(opts[:param_defaults]) 469 470 params_doc = 471 if params == [] do 472 "*There are no specific parameters for this check.*" 473 else 474 param_explanation = 475 Enum.map(params, fn {key, value} -> 476 default_value = inspect(param_defaults[key], limit: :infinity) 477 478 default_hint = 479 if default_value do 480 "*This parameter defaults to* `#{default_value}`." 481 end 482 483 value = value |> String.split("\n") |> Enum.map_join("\n", &" #{&1}") 484 485 """ 486 ### `:#{key}` 487 488 #{value} 489 490 #{default_hint} 491 492 """ 493 end) 494 495 """ 496 Use the following parameters to configure this check: 497 498 #{param_explanation} 499 500 """ 501 end 502 503 """ 504 This check has a base priority of `#{base_priority}` and #{elixir_version_hint}. 505 506 ## Explanation 507 508 #{check_doc} 509 510 ## Check-Specific Parameters 511 512 #{params_doc} 513 514 ## General Parameters 515 516 Like with all checks, [general params](check_params.html) can be applied. 517 518 Parameters can be configured via the [`.credo.exs` config file](config_file.html). 519 """ 520 end 521 522 defp opts_to_string(value) do 523 {result, _} = 524 value 525 |> Macro.to_string() 526 |> Code.eval_string() 527 528 result 529 end 530 531 defp deprecated_def_default_params(env) do 532 default_params = Module.get_attribute(env.module, :default_params) 533 534 if is_nil(default_params) do 535 if not Module.defines?(env.module, {:param_defaults, 0}) do 536 quote do 537 @impl true 538 def param_defaults, do: [] 539 end 540 end 541 else 542 # deprecated - remove once we ditch @default_params 543 quote do 544 @impl true 545 def param_defaults do 546 @default_params 547 end 548 end 549 end 550 end 551 552 defp deprecated_def_explanations(env) do 553 defines_deprecated_explanation_module_attribute? = 554 !is_nil(Module.get_attribute(env.module, :explanation)) 555 556 defines_deprecated_explanations_fun? = Module.defines?(env.module, {:explanations, 0}) 557 558 if defines_deprecated_explanation_module_attribute? do 559 # deprecated - remove once we ditch @explanation 560 quote do 561 @impl true 562 def explanations do 563 @explanation 564 end 565 end 566 else 567 if !defines_deprecated_explanations_fun? do 568 quote do 569 @impl true 570 def explanations, do: [] 571 end 572 end 573 end 574 end 575 576 def explanation_for(nil, _), do: nil 577 def explanation_for(keywords, key), do: keywords[key] 578 579 @doc """ 580 format_issue takes an issue_meta and returns an issue. 581 The resulting issue can be made more explicit by passing the following 582 options to `format_issue/2`: 583 584 - `:priority` Sets the issue's priority. 585 - `:trigger` Sets the issue's trigger. 586 - `:line_no` Sets the issue's line number. Tries to find `column` if `:trigger` is supplied. 587 - `:column` Sets the issue's column. 588 - `:exit_status` Sets the issue's exit_status. 589 - `:severity` Sets the issue's severity. 590 """ 591 def format_issue(issue_meta, opts, check) do 592 params = IssueMeta.params(issue_meta) 593 issue_category = Params.category(params, check) 594 issue_base_priority = Params.priority(params, check) 595 596 format_issue(issue_meta, opts, issue_category, issue_base_priority, check) 597 end 598 599 @doc false 600 def format_issue(issue_meta, opts, issue_category, issue_priority, check) do 601 source_file = IssueMeta.source_file(issue_meta) 602 params = IssueMeta.params(issue_meta) 603 604 priority = Priority.to_integer(issue_priority) 605 606 exit_status_or_category = Params.exit_status(params, check) || issue_category 607 exit_status = Check.to_exit_status(exit_status_or_category) 608 609 line_no = opts[:line_no] 610 trigger = opts[:trigger] 611 column = opts[:column] 612 severity = opts[:severity] || Severity.default_value() 613 614 %Issue{ 615 priority: priority, 616 filename: source_file.filename, 617 message: opts[:message], 618 trigger: trigger, 619 line_no: line_no, 620 column: column, 621 severity: severity, 622 exit_status: exit_status 623 } 624 |> add_line_no_options(line_no, source_file) 625 |> add_column_if_missing(trigger, line_no, column, source_file) 626 |> add_check_and_category(check, issue_category) 627 end 628 629 defp add_check_and_category(issue, check, issue_category) do 630 %Issue{ 631 issue 632 | check: check, 633 category: issue_category 634 } 635 end 636 637 defp add_column_if_missing(issue, trigger, line_no, column, source_file) do 638 if trigger && line_no && !column do 639 %Issue{ 640 issue 641 | column: SourceFile.column(source_file, line_no, trigger) 642 } 643 else 644 issue 645 end 646 end 647 648 defp add_line_no_options(issue, line_no, source_file) do 649 if line_no do 650 {_def, scope} = scope_for(source_file, line: line_no) 651 652 %Issue{ 653 issue 654 | priority: issue.priority + priority_for(source_file, scope), 655 scope: scope 656 } 657 else 658 issue 659 end 660 end 661 662 # Returns the scope for the given line as a tuple consisting of the call to 663 # define the scope (`:defmodule`, `:def`, `:defp` or `:defmacro`) and the 664 # name of the scope. 665 # 666 # Examples: 667 # 668 # {:defmodule, "Foo.Bar"} 669 # {:def, "Foo.Bar.baz"} 670 # 671 @doc false 672 def scope_for(source_file, line: line_no) do 673 source_file 674 |> scope_list 675 |> Enum.at(line_no - 1) 676 end 677 678 # Returns all scopes for the given source_file per line of source code as tuple 679 # consisting of the call to define the scope 680 # (`:defmodule`, `:def`, `:defp` or `:defmacro`) and the name of the scope. 681 # 682 # Examples: 683 # 684 # [ 685 # {:defmodule, "Foo.Bar"}, 686 # {:def, "Foo.Bar.baz"}, 687 # {:def, "Foo.Bar.baz"}, 688 # {:def, "Foo.Bar.baz"}, 689 # {:def, "Foo.Bar.baz"}, 690 # {:defmodule, "Foo.Bar"} 691 # ] 692 defp scope_list(%SourceFile{} = source_file) do 693 case SourceFileScopes.get(source_file) do 694 {:ok, value} -> 695 value 696 697 :notfound -> 698 ast = SourceFile.ast(source_file) 699 lines = SourceFile.lines(source_file) 700 scope_info_list = Scope.scope_info_list(ast) 701 702 result = 703 Enum.map(lines, fn {line_no, _} -> 704 Scope.name_from_scope_info_list(scope_info_list, line_no) 705 end) 706 707 SourceFileScopes.put(source_file, result) 708 709 result 710 end 711 end 712 713 defp priority_for(source_file, scope) do 714 scope_prio_map = Priority.scope_priorities(source_file) 715 716 scope_prio_map[scope] || 0 717 end 718 719 defp category_body(nil) do 720 quote do 721 name = 722 __MODULE__ 723 |> Module.split() 724 |> Enum.at(2) 725 726 safe_name = name || :unknown 727 728 safe_name 729 |> to_string 730 |> String.downcase() 731 |> String.to_atom() 732 end 733 end 734 735 defp category_body(value), do: value 736 737 @doc "Converts a given category to an exit status" 738 def to_exit_status(nil), do: 0 739 740 def to_exit_status(atom) when is_atom(atom) do 741 to_exit_status(@base_category_exit_status_map[atom]) 742 end 743 744 def to_exit_status(value) when is_number(value), do: value 745 746 @doc false 747 def defined?(check) 748 749 def defined?({atom, _params}), do: defined?(atom) 750 751 def defined?(binary) when is_binary(binary) do 752 binary |> String.to_atom() |> defined?() 753 end 754 755 def defined?(module) when is_atom(module) do 756 case Code.ensure_compiled(module) do 757 {:module, _} -> true 758 {:error, _} -> false 759 end 760 end 761 end