Current section
Files
Jump to
Current section
Files
lib/assay/formatter.ex
defmodule Assay.Formatter do
@moduledoc """
Formats Dialyzer warnings into various output formats.
This module converts decorated Dialyzer warning entries into formatted strings
suitable for different consumers: humans (text), CI systems (github, sarif),
editors (lsp), and LLM/agent tools (json, llm).
Supports multiple formats: `:text`, `:elixir`, `:github`, `:json`, `:sarif`, `:llm`.
See `format/3` for details on each format.
"""
alias Assay.Formatter.Warning
@doc """
Formats decorated Dialyzer warnings into strings for the requested output format.
## Formats
- `:text` - Human-readable text format with code snippets and location information
- `:elixir` - Same as `:text` but with pretty-printed Erlang terms (requires `erlex` dependency)
- `:github` - GitHub Actions workflow annotations (`::warning file=...::message`)
- `:json` - JSON objects for machine/RPC consumers (one JSON object per warning)
- `:sarif` - SARIF 2.1.0 log (entire log emitted as a single JSON string)
- `:llm` - JSON format optimized for LLM consumption (single-line messages, structured data)
## Options
- `:project_root` (required) - The root directory of the project for resolving relative paths
## Examples
iex> entry = %{
...> text: "Function will never return",
...> match_text: "Function will never return",
...> path: "lib/bar.ex",
...> relative_path: "lib/bar.ex",
...> line: 5,
...> code: :warn_not_called
...> }
iex> [result] = Assay.Formatter.format([entry], :github, project_root: "/project")
iex> result
"::warning file=lib/bar.ex,line=5::Function will never return"
iex> entry = %{
...> text: "Type mismatch",
...> match_text: "Type mismatch",
...> path: "lib/baz.ex",
...> relative_path: "lib/baz.ex",
...> line: 10,
...> column: 5,
...> code: :warn_matching
...> }
iex> [result] = Assay.Formatter.format([entry], :llm, project_root: "/project")
iex> {:ok, json} = JSON.decode(result)
iex> json["code"]
"warn_matching"
iex> json["line"]
10
iex> json["severity"]
"warning"
"""
@spec format([map()], atom(), keyword()) :: [String.t()]
def format(entries, :text, opts) do
project_root = Keyword.fetch!(opts, :project_root)
Enum.map(entries, fn entry ->
format_text_entry(entry, project_root, opts)
end)
end
def format(entries, :elixir, opts) do
format(entries, :text, Keyword.put(opts, :pretty_erlang, true))
end
def format(entries, :github, opts) do
project_root = Keyword.fetch!(opts, :project_root)
Enum.map(entries, fn entry ->
path = entry.relative_path || relative_display(entry.path, project_root) || "unknown"
line = entry.line || 0
message = entry |> entry_message() |> github_escape()
"::warning file=#{path},line=#{line}::#{message}"
end)
end
def format(entries, :llm, opts) do
project_root = Keyword.fetch!(opts, :project_root)
entries
|> Enum.map(fn entry ->
payload = warning_payload(entry, project_root)
relative = payload["relative_path"] || "unknown"
%{
code: payload["code"],
location: payload["location"],
file: relative,
line: entry.line,
column: entry.column,
message: payload["message_single_line"],
severity: payload["severity"]
}
|> JSON.encode!()
end)
end
def format(entries, :json, opts) do
project_root = Keyword.fetch!(opts, :project_root)
Enum.map(entries, fn entry ->
entry
|> warning_payload(project_root)
|> JSON.encode!()
end)
end
def format(entries, :sarif, opts) do
project_root = Keyword.fetch!(opts, :project_root)
sarif = %{
"version" => "2.1.0",
"$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
"runs" => [
%{
"tool" => %{
"driver" => %{
"name" => "Assay",
"informationUri" => "https://github.com/ch4s3/assay"
}
},
"results" => Enum.map(entries, &sarif_result(&1, project_root))
}
]
}
[JSON.encode!(sarif)]
end
@doc false
@spec warning_payload(map(), binary()) :: map()
def warning_payload(entry, project_root) do
relative = entry.relative_path || relative_display(entry.path, project_root) || "unknown"
%{
"message" => entry.text || entry_message(entry),
"message_single_line" => single_line_message(entry),
"match" => entry.match_text,
"path" => entry.path,
"relative_path" => relative,
"line" => entry.line,
"column" => entry.column,
"code" => code_to_string(entry.code),
"severity" => warning_severity(entry.code),
"location" => format_location(relative, entry.line, entry.column),
"project_root" => project_root
}
end
defp sarif_result(entry, project_root) do
relative = entry.relative_path || relative_display(entry.path, project_root) || entry.path
location =
%{
"artifactLocation" => %{
"uri" => relative || entry.path || "unknown"
}
}
|> maybe_put_region(entry)
%{
"ruleId" => code_to_string(entry.code) || "dialyzer",
"level" => sarif_level(entry.code),
"message" => %{"text" => entry_message(entry)},
"locations" => [%{"physicalLocation" => location}],
"properties" => %{
"match" => entry.match_text,
"raw_path" => entry.path
}
}
end
defp maybe_put_region(location, entry) do
region =
case {entry.line, entry.column} do
{line, column} when is_integer(line) and is_integer(column) ->
%{"startLine" => line, "startColumn" => column}
{line, _} when is_integer(line) ->
%{"startLine" => line}
_ ->
nil
end
case region do
nil -> location
_ -> Map.put(location, "region", region)
end
end
defp format_text_entry(entry, project_root, opts) do
relative = entry.relative_path || relative_display(entry.path, project_root) || "nofile"
warning =
Warning.render(entry,
relative_path: relative,
color?: Keyword.get(opts, :color?, false)
)
location = format_location(relative, entry.line, entry.column)
code_line = format_code_line(entry.code)
snippet = format_snippet(entry.path, entry.line, entry.column)
[
"┌─ warning: #{location}",
code_line,
snippet || "│"
]
|> Kernel.++(format_detail_lines(warning.details, opts))
|> Kernel.++(["└─ #{warning.headline}"])
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
defp entry_message(entry) do
entry.match_text || entry.text || "Dialyzer warning"
end
defp single_line_message(entry) do
entry
|> entry_message()
|> String.replace("\n", " ")
|> String.replace("\r", " ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
defp format_location(relative, line, column) do
base = relative || "unknown"
cond do
line && column -> "#{base}:#{line}:#{column}"
line -> "#{base}:#{line}"
true -> base
end
end
defp format_code_line(nil), do: nil
defp format_code_line(code) do
pretty =
code
|> Atom.to_string()
|> String.trim_leading("warn_")
|> String.replace("_", " ")
"│ (#{pretty})"
end
defp format_snippet(nil, _line, _column), do: nil
defp format_snippet(_path, nil, _column), do: nil
defp format_snippet(path, line, column) do
with true <- File.regular?(path),
{:ok, contents} <- File.read(path),
lines_list when is_list(lines_list) <- fetch_context_lines(contents, line, 2) do
digits = max_line_digits(lines_list)
error_line_idx = Enum.find_index(lines_list, &(&1.line == line))
snippet_lines =
lines_list
|> Enum.with_index()
|> Enum.flat_map(fn {line_data, idx} ->
format_context_line(line_data, digits, idx == error_line_idx, column)
end)
["│"] ++ snippet_lines ++ ["│"]
else
_ -> nil
end
end
defp fetch_context_lines(contents, target_line, context_lines) do
all_lines = String.split(contents, "\n", trim: false)
start_line = max(1, target_line - context_lines)
end_line = min(length(all_lines), target_line + context_lines)
start_line..end_line
|> Enum.map(fn line_num ->
%{
line: line_num,
content:
Enum.at(all_lines, line_num - 1)
|> String.trim_trailing("\n")
|> String.trim_trailing("\r")
}
end)
end
defp max_line_digits(lines_list) do
lines_list
|> Enum.map(fn %{line: line} -> line end)
|> Enum.map(&Integer.to_string/1)
|> Enum.map(&String.length/1)
|> Enum.max(fn -> 1 end)
end
defp format_context_line(%{line: line, content: content}, digits, is_error_line, column) do
line_label = String.pad_leading(Integer.to_string(line), digits)
sanitized = sanitize_line(content)
base_line = "#{line_label} │ #{sanitized}"
if is_error_line and column do
# Add underline for the problematic segment
underline = create_underline(sanitized, column, digits)
[base_line, underline]
else
[base_line]
end
end
defp create_underline(line, column, digits) do
indent = max(column - 1, 0)
gutter = String.duplicate(" ", digits)
# Calculate underline length (try to match the problematic token)
underline_length = calculate_underline_length(line, column)
"#{gutter} │ #{String.duplicate(" ", indent)}#{String.duplicate("~", underline_length)}"
end
defp calculate_underline_length(line, column) do
# Try to find the token at this column
# For now, default to 3 characters, but could be enhanced to detect actual token boundaries
min(3, max(1, String.length(line) - column + 1))
end
defp sanitize_line(line) do
line
|> String.trim_trailing("\n")
|> String.trim_trailing("\r")
end
defp format_detail_lines([], _opts), do: []
defp format_detail_lines(details, opts) do
pretty_erlang? = Keyword.get(opts, :pretty_erlang, false)
details
|> drop_leading_blank()
|> maybe_pretty_erlang(pretty_erlang?)
|> strip_common_indent()
|> highlight_detail_lines()
|> Enum.map(fn
"" -> "│"
line -> "│ " <> line
end)
end
defp drop_leading_blank(["" | rest]), do: drop_leading_blank(rest)
defp drop_leading_blank([]), do: []
defp drop_leading_blank(lines), do: lines
defp maybe_pretty_erlang(lines, true) do
if Code.ensure_loaded?(Erlex) do
convert_erlang_blocks(lines)
else
lines
end
end
defp maybe_pretty_erlang(lines, _), do: lines
defp convert_erlang_blocks(lines) do
do_convert_erlang(lines, [])
end
defp do_convert_erlang([], acc), do: Enum.reverse(acc)
defp do_convert_erlang([line | rest], acc) do
trimmed = String.trim_leading(line)
if erlang_block_start?(trimmed) do
{chunk_lines, remaining} = take_block(rest, paren_delta(line), [line])
converted = pretty_block(chunk_lines)
do_convert_erlang(remaining, Enum.reverse(converted) ++ acc)
else
do_convert_erlang(rest, [line | acc])
end
end
defp erlang_block_start?(line) do
String.starts_with?(line, ["(", "\#{"])
end
defp take_block(rest, balance, acc) when balance <= 0 do
{Enum.reverse(acc), rest}
end
defp take_block([], _balance, acc) do
{Enum.reverse(acc), []}
end
defp take_block([line | tail], balance, acc) do
delta = paren_delta(line)
take_block(tail, balance + delta, [line | acc])
end
defp paren_delta(line) do
line
|> String.graphemes()
|> Enum.reduce(0, fn
"(", acc -> acc + 1
")", acc -> acc - 1
_, acc -> acc
end)
end
defp pretty_block(lines) do
chunk =
lines
|> Enum.join("\n")
|> String.trim()
case run_erlex(chunk) do
{:ok, pretty} ->
pretty
|> String.trim()
|> String.split("\n")
:error ->
lines
end
end
@erlex_module :"Elixir.Erlex"
defp run_erlex(chunk) do
if :erlang.function_exported(@erlex_module, :pretty_print, 1) do
try do
{:ok, :erlang.apply(@erlex_module, :pretty_print, [chunk])}
rescue
_ -> :error
catch
_, _ -> :error
end
else
:error
end
end
defp highlight_detail_lines(lines) do
Enum.flat_map(lines, fn line ->
if reason_line?(line) do
{before, after_part} = split_on_phrase(line, "will never return")
trimmed_before = String.trim_trailing(before)
reason =
["will never return", after_part]
|> Enum.join()
|> String.trim()
parts =
[]
|> maybe_append(trimmed_before)
|> Kernel.++(["-> " <> reason])
parts
else
[line]
end
end)
end
defp maybe_append(list, ""), do: list
defp maybe_append(list, item), do: list ++ [item]
defp split_on_phrase(line, phrase) do
case String.split(line, phrase, parts: 2) do
[before, after_part] -> {before, after_part}
_ -> {line, ""}
end
end
defp reason_line?(line) do
String.contains?(line, "will never return")
end
defp strip_common_indent(lines) do
indent =
lines
|> Enum.filter(&(String.trim(&1) != ""))
|> Enum.map(&leading_indent/1)
|> case do
[] -> 0
counts -> Enum.min(counts)
end
if indent == 0 do
lines
else
Enum.map(lines, fn
"" -> ""
line -> drop_indent(line, indent)
end)
end
end
defp drop_indent(line, indent) do
trimmed = min(indent, byte_size(line))
binary_part(line, trimmed, byte_size(line) - trimmed)
end
defp leading_indent(line), do: leading_indent(line, 0)
defp leading_indent(<<char::utf8, rest::binary>>, acc) when char in [?\s, ?\t],
do: leading_indent(rest, acc + 1)
defp leading_indent(_rest, acc), do: acc
defp relative_display(nil, _root), do: nil
defp relative_display(path, root) do
Path.relative_to(path, root)
rescue
_ -> path
end
defp github_escape(message) do
message
|> String.replace("%", "%25")
|> String.replace("\r", "%0D")
|> String.replace("\n", "%0A")
end
defp warning_severity(code) when is_atom(code) do
case Atom.to_string(code) do
"warn_" <> _ -> "warning"
_ -> "info"
end
end
defp warning_severity(_), do: "warning"
defp code_to_string(nil), do: nil
defp code_to_string(code) when is_atom(code), do: Atom.to_string(code)
defp code_to_string(other), do: to_string(other)
defp sarif_level(code) do
case warning_severity(code) do
"warning" -> "warning"
_ -> "note"
end
end
end