defmodule ExCrap do
@moduledoc """
Public API for calculating CRAP scores from complexity and coverage data.
Use `score/2` to calculate a CRAP score directly. Use `analyze_string/2` or
`analyze_file/2` to analyze Elixir source with explicit function coverage data.
Use `mix crap` for a project scan from exported Mix/Erlang coverdata. The task
enforces a maximum CRAP score threshold (default `30`) and fails with a non-zero
exit when any function exceeds it or any score calculation error occurs.
"""
use Boundary
alias ExCrap.Complexity
alias ExCrap.Coverage
alias ExCrap.Report
alias ExCrap.Scanner
alias ExCrap.Score
@doc """
Analyzes one Elixir source file and combines each discovered function with explicit coverage.
This is a single-file convenience wrapper around the internal complexity analyzer.
Valid files with no analyzable function or macro bodies return `{:ok, []}`.
It does not perform project-wide scanning or coverage discovery.
"""
def analyze_file(path, coverage_by_function) when is_map(coverage_by_function) do
with {:ok, functions} <- Complexity.from_file(path) do
{:ok, Enum.map(functions, &score_function(&1, coverage_by_function))}
end
end
def analyze_file(_path, _coverage_by_function), do: {:error, :invalid_coverage_map}
@doc false
def project_report(root, coverdata_path, opts \\ [])
when is_binary(root) and is_binary(coverdata_path) do
source_path = Keyword.get(opts, :source_path, "lib")
source_pattern = root |> Scanner.source_pattern(source_path) |> Path.relative_to(root)
source_files = Scanner.source_files(root, source_path)
with :ok <- ensure_source_files(source_files, source_pattern),
{:ok, functions} <- Scanner.analyze(root, source_path),
:ok <- ensure_analyzable_functions(functions, source_pattern),
{:ok, coverage} <- Coverage.from_coverdata(coverdata_path) do
{:ok, Report.rows(functions, coverage, root)}
end
end
@doc false
def render_report(rows, opts \\ []) do
Report.render(rows, opts)
end
@doc false
def failures(rows, max_score) do
Report.failures(rows, max_score)
end
@doc """
Analyzes Elixir source and combines each discovered function with explicit coverage.
`coverage_by_function` must be a map keyed by `{module, function_name, arity}`:
%{{Example, :visible?, 1} => 75.0}
Coverage values are percentages from `0` to `100`. Functions without a matching
coverage entry are scored as `0%` covered. This function does not discover or
ingest coverage automatically.
Valid source with no analyzable function or macro bodies returns `{:ok, []}`.
"""
def analyze_string(source, coverage_by_function) when is_map(coverage_by_function) do
with {:ok, functions} <- Complexity.from_string(source) do
{:ok, Enum.map(functions, &score_function(&1, coverage_by_function))}
end
end
def analyze_string(_source, _coverage_by_function), do: {:error, :invalid_coverage_map}
@doc """
Calculates the canonical CRAP score for a complexity and coverage percentage.
The formula is:
complexity^2 * (1 - coverage_percent / 100)^3 + complexity
`complexity` must be numeric and non-negative. `coverage_percent` must be numeric
and between `0` and `100` inclusive. Fractional scores are preserved.
"""
def score(complexity, coverage_percent) do
Score.score(complexity, coverage_percent)
end
defp ensure_source_files([], source_pattern), do: {:no_source_files, source_pattern}
defp ensure_source_files(_source_files, _source_pattern), do: :ok
defp ensure_analyzable_functions([], source_pattern),
do: {:no_analyzable_functions, source_pattern}
defp ensure_analyzable_functions(_functions, _source_pattern), do: :ok
defp score_function(function, coverage_by_function) do
key = {function.module, function.function, function.arity}
coverage_percent = Map.get(coverage_by_function, key, 0)
case score(function.complexity, coverage_percent) do
{:ok, score} ->
function
|> Map.put(:coverage_percent, coverage_percent)
|> Map.put(:score, score)
|> Map.put(:status, :scored)
{:error, reason} ->
Map.put(function, :status, {:error, reason})
end
end
end