defmodule Commitlint do
@moduledoc "README.md" |> File.read!() |> String.trim()
@type lint_result :: :ok | {:error, String.t()}
@type_regex ~r/^(?<type>[a-z]+)(?:\((?<scope>[a-z]+)\))?!?: (?<description>.+)$/
@footer_regex ~r/^(?<label>.+?): (?<value>.+)$/
@default_allowed_types ~w(feat fix docs style refactor perf test build ci chore revert)
@spec get_sections(String.t()) :: [String.t()]
defp get_sections(input) do
# Get the sections of a commit message.
String.split(input, "\n\n")
end
defp lint_header([header | rest]) do
# Make sure the commit message type is allowed.
# Expected format: "type: message" or "type(scope): message"
# ## Examples
# iex> Commitlint.lint_header("feat: add linting to commit messages")
# :ok
# iex> Commitlint.lint_header("inexistent: add a test commit")
# {:error, "Invalid commit type: inexistent"}
allowed_types = Application.get_env(:commitlint, :allowed_types, @default_allowed_types)
captured = Regex.named_captures(@type_regex, header)
cond do
check_skip(header) ->
IO.puts("Skipping commit message: #{header}")
:ok
captured["type"] not in allowed_types ->
{:error, "Invalid commit type: #{captured["type"]}"}
String.trim(captured["description"]) == "" ->
{:error, "Commit message must have a description"}
true ->
lint_body(rest)
end
end
@spec check_skip(String.t()) :: boolean()
defp check_skip(header) do
# Check if the commit message should be skipped.
String.starts_with?(header, "Merge ")
end
@spec lint_body([String.t()]) :: lint_result
defp lint_body([]), do: :ok
defp lint_body([section | rest]) do
# Make sure the commit message body is valid.
if Enum.empty?(rest) and Regex.match?(@footer_regex, section) do
lint_footer(section)
else
lint_body(rest)
end
end
@spec lint_footer(String.t()) :: lint_result
defp lint_footer(footer) do
# Make sure the footer lines are valid.
String.split(footer, "\n", trim: true) |> lint_footer_line
end
@spec lint_footer_line([String.t()]) :: lint_result
defp lint_footer_line([]), do: :ok
defp lint_footer_line([line | rest]) do
# Make sure the footer lines are valid.
if Regex.match?(@footer_regex, line) do
lint_footer_line(rest)
else
{:error, "Invalid footer line: #{line}"}
end
end
@spec filter_out_comments(String.t()) :: String.t()
defp filter_out_comments(input) do
# Removes all lines starting with a hash (#).
String.split(input, "\n")
|> Enum.reject(&String.starts_with?(&1, "#"))
|> Enum.join("\n")
end
@doc """
Lint the commit message.
## Examples
iex> Commitlint.lint!("feat: add linting to commit messages")
:ok
iex> Commitlint.lint!("inexistent: add a test commit")
** (Commitlint.LintException) Invalid commit type: inexistent
"""
@spec lint!(String.t()) :: :ok
def lint!(input) do
result = filter_out_comments(input) |> get_sections |> lint_header
case result do
:ok -> :ok
{:error, message} -> raise Commitlint.LintException, message: message
end
end
defmodule Setup do
@moduledoc """
Not meant to be used directly.
This will make sure that the files in /priv are correctly installed when compiling the project.
It lives in its own submodule so that it does not pollute runtime code.
This was inspired by the way [elixir-pre-commit](https://github.com/dwyl/elixir-pre-commit/) works.
"""
copy = Mix.Project.deps_path() |> Path.join("commitlint/priv/commit-msg")
to = Mix.Project.deps_path() |> Path.join("../.git/hooks/commit-msg")
if not File.exists?(Path.dirname(to)) do
File.mkdir_p!(Path.dirname(to))
end
copy |> File.copy(to)
to |> File.chmod(0o755)
end
end