From dc9c61f7db57bfaa9f5c8733b50997244f904f23 Mon Sep 17 00:00:00 2001 From: Thomas Athanas Date: Thu, 21 May 2026 05:57:27 +0000 Subject: [PATCH 1/3] add heatmap to html formatter --- README.md | 4 +- lib/six/config.ex | 7 +- lib/six/cover.ex | 31 ++ lib/six/formatters/html.ex | 957 +++++++++++++++++++++++++++++---- lib/six/heatmap.ex | 48 ++ lib/six/ignore.ex | 7 +- lib/six/ignore_functions.ex | 93 +++- lib/six/report.ex | 7 +- lib/six/stats.ex | 82 ++- test/config_test.exs | 5 + test/formatters/html_test.exs | 322 +++++++++-- test/heatmap_test.exs | 29 + test/ignore_functions_test.exs | 22 + test/stats_test.exs | 23 +- test/test_helper.exs | 5 + 15 files changed, 1444 insertions(+), 198 deletions(-) create mode 100644 lib/six/heatmap.ex create mode 100644 test/heatmap_test.exs diff --git a/README.md b/README.md index fbcb978..3d6c2b1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # Six - - Six - +![Six](https://raw.githubusercontent.com/typicalpixel/six/main/assets/six.png) ## Watch your Coverage diff --git a/lib/six/config.ex b/lib/six/config.ex index e3a290b..9e6558a 100644 --- a/lib/six/config.ex +++ b/lib/six/config.ex @@ -10,7 +10,8 @@ defmodule Six.Config do detail: false, filter: nil, threshold: 90, - track_ignores: false + track_ignores: false, + heatmap: true @doc """ Reads configuration from application env and returns a config struct. @@ -26,7 +27,8 @@ defmodule Six.Config do detail: get(:detail, false), filter: get(:filter, nil), threshold: get(:threshold, 90), - track_ignores: get(:track_ignores, false) + track_ignores: get(:track_ignores, false), + heatmap: get(:heatmap, true) } end @@ -42,6 +44,7 @@ defmodule Six.Config do {:filter, val}, acc -> %{acc | filter: val} {:formatters, val}, acc -> %{acc | formatters: val} {:track_ignores, val}, acc -> %{acc | track_ignores: val} + {:heatmap, val}, acc -> %{acc | heatmap: val} {:skip, val}, acc -> %{acc | skip_files: acc.skip_files ++ [val]} {:skip_files, vals}, acc when is_list(vals) -> %{acc | skip_files: acc.skip_files ++ vals} {:summary, summary_opts}, acc -> merge_summary_opts(acc, summary_opts) diff --git a/lib/six/cover.ex b/lib/six/cover.ex index 7aa43e5..6acf1ff 100644 --- a/lib/six/cover.ex +++ b/lib/six/cover.ex @@ -57,6 +57,37 @@ defmodule Six.Cover do end) end + @doc """ + Analyzes a single module for per-function call counts. + Returns `{:ok, [{{mod, fun, arity}, count}]}` or `{:error, reason}`. + """ + def analyze_functions(module) do + case :cover.analyse(module, :calls, :function) do + {:ok, results} -> + {:ok, results} + + # six:ignore:start + {:error, reason} -> + {:error, reason} + # six:ignore:stop + end + end + + @doc """ + Analyzes per-function call counts for all cover-compiled modules. + Returns a map of module => [{{mod, fun, arity}, count}]. + """ + def analyze_all_functions do + :cover.modules() + |> Enum.reduce(%{}, fn module, acc -> + case analyze_functions(module) do + {:ok, results} -> Map.put(acc, module, results) + # six:ignore:next + {:error, _} -> acc + end + end) + end + @doc """ Resolves the source file path for a module, relative to the project root. Returns nil if the source file doesn't exist. diff --git a/lib/six/formatters/html.ex b/lib/six/formatters/html.ex index 05af885..c4d65ba 100644 --- a/lib/six/formatters/html.ex +++ b/lib/six/formatters/html.ex @@ -2,6 +2,9 @@ defmodule Six.Formatters.HTML do @moduledoc false @behaviour Six.Formatter + alias Six.Heatmap + alias Six.Ignore + Module.register_attribute(__MODULE__, :six, accumulate: true) @impl true @@ -22,23 +25,11 @@ defmodule Six.Formatters.HTML do Path.join(Keyword.get(opts, :output_dir, ".six"), "coverage.html") end - defp render(summary, opts) do + @doc false + def render(summary, opts) do threshold = Keyword.get(opts, :threshold, 90) - render_inline(summary, threshold) - end - - defp render_inline(summary, threshold) do - files_html = - summary.files - |> Enum.sort_by(& &1.percentage) - |> Enum.map(&file_row_html(&1, threshold)) - |> Enum.join("\n") - - file_details = - summary.files - |> Enum.sort_by(& &1.percentage) - |> Enum.map(&file_detail_html/1) - |> Enum.join("\n") + heatmap? = Keyword.get(opts, :heatmap, true) + files = Enum.sort_by(summary.files, & &1.percentage) """ @@ -46,121 +37,887 @@ defmodule Six.Formatters.HTML do - Six Coverage Report + six · coverage -

Six Coverage Report

-
- Total: #{format_pct(summary.percentage)} - — #{summary.total_covered}/#{summary.total_relevant} relevant lines covered -
- - - - #{files_html} - + #{app_bar(summary, threshold)} +
+ #{toolbar(files)} +
+
FileCoverageLinesRelevantMissed
+ + + + + + + + + + + + + #{Enum.map_join(files, "\n", &module_rows(&1, heatmap?))} +
file cov  lines relevant missed max×
- #{file_details} + + #{ignored_section(files)} +
six · generated #{stamp()} · single-file report · works offline
+ + + """ end - defp file_row_html(file, threshold) do - color = if file.percentage >= threshold, do: "pct-good", else: "pct-bad" - bar_color = if file.percentage >= threshold, do: "var(--green)", else: "var(--red)" - file_id = file.path |> String.replace(~r/[^a-zA-Z0-9]/, "-") + # ---------------------------------------------------------------------- + # App bar + toolbar + # ---------------------------------------------------------------------- + + defp app_bar(summary, threshold) do + pct = summary.percentage + passing? = pct >= threshold + color = if passing?, do: cov_color(pct), else: "var(--cov-15)" + status = if passing?, do: "passing", else: "below" """ - - #{escape_html(file.path)} - - #{format_pct(file.percentage)} -
- - #{file.lines} - #{file.relevant} - #{file.missed} - +
+
+ + six + · + coverage +
+
+ generated #{stamp()} + · + #{comma(summary.total_lines)} lines + · + #{comma(summary.total_relevant)} relevant + · + peak ×#{comma(Map.get(summary, :project_max_hits, 0))} +
+
+
#{format_pct(pct)}coverage
+
#{format_pct(threshold)}threshold
+
#{comma(summary.total_covered)}covered
+
#{comma(summary.total_missed)}missed
+
+
""" end - defp file_detail_html(file) do - file_id = file.path |> String.replace(~r/[^a-zA-Z0-9]/, "-") - source_lines = String.split(file.source, "\n") + defp toolbar(files) do + """ +
+

Modules

+ #{length(files)} files · sorted by coverage ascending +
+
+ + +
+
+ """ + end - lines_html = - source_lines - |> Enum.zip(file.coverage) - |> Enum.with_index(1) - |> Enum.map(fn {{line, cov}, num} -> - class = - case cov do - nil -> "" - 0 -> " class=\"miss\"" - _ -> " class=\"hit\"" - end - - "#{num}#{escape_html(line)}" + # ---------------------------------------------------------------------- + # Module index rows + # ---------------------------------------------------------------------- + + defp module_rows(file, heatmap?) do + id = file_id(file.path) + color = cov_color(file.percentage) + max = Map.get(file, :max_hits, 0) + miss_class = if file.missed > 0, do: "miss-cell has", else: "miss-cell" + + max_cell = + if max > 0, + do: fmt_hits(max), + else: ~s() + + """ + + #{escape(file.path)} + #{format_pct(file.percentage)} +
+ #{file.lines} + #{file.relevant} + #{file.missed} + #{max_cell} + + #{source_pane(file, heatmap?)} + """ + end + + defp bar_width(pct) when pct < 1.2, do: 1.2 + defp bar_width(pct), do: pct + + # ---------------------------------------------------------------------- + # Source pane + # ---------------------------------------------------------------------- + + defp source_pane(file, heatmap?) do + max = Map.get(file, :max_hits, 0) + + miss = + if file.missed > 0, + do: ~s(#{file.missed} missed), + else: "" + + peak = + if max > 0, + do: ~s(peak ×#{comma(max)}), + else: "" + + """ +
+
+ #{format_pct(file.percentage)} coverage + #{file.lines} lines + #{file.relevant} relevant + #{miss} + #{peak} + #{if heatmap?, do: scale(max), else: ""} +
+
#{code_lines(file, heatmap?)}
+ #{if heatmap?, do: source_footer(), else: ""} +
+ """ + end + + defp scale(max) do + bars = + 0..5 + |> Enum.map_join("", fn b -> ~s|| end) + + ~s(×0#{bars}×#{fmt_hits(max)}) + end + + defp source_footer do + legends = + [ + {"", "×0 (never hit)"}, + {"h1", "×1"}, + {"h2", "×2–9"}, + {"h3", "×10–99"}, + {"h4", "×100–999"}, + {"h5", "×1k+"} + ] + |> Enum.map_join("\n", fn {cls, label} -> + ~s( #{label}) end) - |> Enum.join("\n") """ -
-

#{escape_html(file.path)}

-
#{lines_html}
+ """ end + # Render source lines, wrapping each function's lines in a hover region. + defp code_lines(file, heatmap?) do + source_lines = String.split(file.source, "\n") + coverage = file.coverage + funcs = if heatmap?, do: function_index(file, coverage), else: %{} + + {html, open} = + source_lines + |> Enum.zip(coverage) + |> Enum.with_index(1) + |> Enum.reduce({[], nil}, fn {{line, cov}, num}, {acc, open} -> + fun = Map.get(funcs, num) + {prefix, open} = transition(open, fun) + {[acc, prefix, line_html(num, line, cov, heatmap?)], open} + end) + + [html, if(open, do: "
", else: "")] + |> IO.iodata_to_binary() + end + + # Emits closing/opening fn-range wrappers as the active function changes. + defp transition(same, same), do: {"", same} + defp transition(nil, new), do: {open_fn(new), new} + defp transition(_old, nil), do: {"", nil} + defp transition(_old, new), do: {["", open_fn(new)], new} + + defp open_fn(%{tip: tip}), do: ~s(
#{tip}
) + + defp line_html(num, line, cov, heatmap?) do + ~s(
#{num}#{src_html(line)}
) + end + + defp src_html(""), do: " " + defp src_html(line), do: highlight(line) + + defp line_heat_class(cov, true), do: "h-" <> heat_suffix(Heatmap.bucket(cov)) + defp line_heat_class(nil, false), do: "h-nil" + defp line_heat_class(0, false), do: "h-0" + defp line_heat_class(_, false), do: "h-cov" + + defp heat_suffix(nil), do: "nil" + defp heat_suffix(:cold), do: "0" + defp heat_suffix(n), do: Integer.to_string(n) + + # Map of line_number => %{tip: html} for each instrumented function line. + defp function_index(file, coverage) do + by_string = + file + |> Map.get(:function_calls, %{}) + |> Map.new(fn + {{module, fun, arity}, count} -> {{module, Atom.to_string(fun), arity}, count} + {{fun, arity}, count} -> {{nil, Atom.to_string(fun), arity}, count} + end) + + basename = file.path |> Path.basename() |> String.replace_suffix(".ex", "") + + funcs = + file.source + |> Ignore.Functions.functions() + |> Enum.reject(&entirely_uninstrumented?(&1, coverage)) + + Enum.reduce(funcs, %{}, fn fun, acc -> + tip = fn_tip(fun, basename, by_string, coverage) + entry = %{key: {fun.start_line, fun.end_line}, tip: tip} + Enum.reduce(fun.start_line..fun.end_line, acc, &Map.put(&2, &1, entry)) + end) + end + + defp fn_tip(fun, basename, by_string, coverage) do + name = bare_name(fun.function) + label = if fun.arity, do: "#{name}/#{fun.arity}", else: name + + # Instrumented functions always have a count (line fallback below never + # returns nil once entirely-uninstrumented functions are filtered out). + calls = + Map.get(by_string, {fun.module, name, fun.arity}) || + Map.get(by_string, {nil, name, fun.arity}) || + max_in_range(coverage, fun.start_line, fun.end_line) + + ~s(#{escape(basename)}.#{escape(label)} ×#{comma(calls)} calls) + end + + defp entirely_uninstrumented?(%{start_line: s, end_line: e}, coverage) do + coverage |> Enum.slice((s - 1)..(e - 1)) |> Enum.all?(&is_nil/1) + end + + defp bare_name(function) do + function |> String.split(" ", parts: 2) |> List.last() + end + + defp max_in_range(coverage, s, e) do + coverage + |> Enum.slice((s - 1)..(e - 1)) + |> Enum.reduce(nil, fn + n, acc when is_integer(n) and (acc == nil or n > acc) -> n + _, acc -> acc + end) + end + + # ---------------------------------------------------------------------- + # Ignored section (dimmed, at the bottom — not the focus) + # ---------------------------------------------------------------------- + + defp ignored_section(files) do + entries = + Enum.flat_map(files, fn file -> + comment_entries = + Enum.map(Ignore.ignored_ranges(file.source), fn {s, e, type} -> + label = if type == :block, do: "six:ignore:start/stop", else: "six:ignore:next" + {file.path, s, e, label, nil} + end) + + func_entries = + Enum.map(Ignore.Functions.ignored_functions(file.source), fn %{ + start_line: s, + end_line: e, + function: func + } -> + {file.path, s, e, "@six :ignore", func} + end) + + comment_entries ++ func_entries + end) + + case entries do + [] -> + "" + + entries -> + rows = + Enum.map_join(entries, "\n", fn {path, s, e, label, func} -> + func_part = if func, do: ~s( #{escape(func)}), else: "" + + ~s(
  • #{escape(path)}:#{s}–#{e}#{func_part} #{label}
  • ) + end) + + """ +
    +

    Explicitly ignored #{length(entries)}

    +

    Excluded from coverage on purpose — not counted above.

    +
      + #{rows} +
    +
    + """ + end + end + + # ---------------------------------------------------------------------- + # JSON payloads (for copy-as-markdown / plaintext) — no JSON dep + # ---------------------------------------------------------------------- + + defp index_json(files) do + files + |> Enum.map_join(",", fn f -> + ~s({"path":#{json_string(f.path)},"pct":#{f.percentage},"lines":#{f.lines},"relevant":#{f.relevant},"missed":#{f.missed},"max":#{Map.get(f, :max_hits, 0)}}) + end) + |> then(&"[#{&1}]") + end + + defp total_json(summary) do + ~s({"pct":#{summary.percentage},"relevant":#{summary.total_relevant},"covered":#{summary.total_covered},"missed":#{summary.total_missed}}) + end + + defp json_string(str) do + escaped = + str + |> String.graphemes() + |> Enum.map_join(&json_escape/1) + + "\"#{escaped}\"" + end + + defp json_escape("\""), do: "\\\"" + defp json_escape("\\"), do: "\\\\" + defp json_escape("\b"), do: "\\b" + defp json_escape("\f"), do: "\\f" + defp json_escape("\n"), do: "\\n" + defp json_escape("\r"), do: "\\r" + defp json_escape("\t"), do: "\\t" + defp json_escape("<"), do: "\\u003c" + defp json_escape(">"), do: "\\u003e" + defp json_escape("&"), do: "\\u0026" + defp json_escape(g), do: g + + # ---------------------------------------------------------------------- + # Formatting helpers + # ---------------------------------------------------------------------- + + defp file_id(path), do: "file-" <> String.replace(path, ~r/[^a-zA-Z0-9]/, "-") + defp format_pct(pct), do: :erlang.float_to_binary(pct / 1, decimals: 1) <> "%" - defp escape_html(str) do + # File coverage ramp: 100% deep green down through lime/amber/pink to red — + # a half-covered file IS half-tested, so the whole spectrum is in play. + @cov_stops [ + {100, "#15803d"}, + {95, "#16a34a"}, + {85, "#22c55e"}, + {75, "#65a30d"}, + {65, "#84cc16"}, + {55, "#ca8a04"}, + {50, "#d97706"}, + {40, "#db2777"}, + {30, "#e11d48"}, + {15, "#dc2626"}, + {0, "#991b1b"} + ] + + defp cov_color(pct) do + {_bound, hex} = Enum.find(@cov_stops, fn {bound, _} -> pct >= bound end) + hex + end + + defp fmt_hits(n) when n >= 1000 do + decimals = if n >= 10_000, do: 0, else: 1 + + (n / 1000) + |> :erlang.float_to_binary(decimals: decimals) + |> String.replace_suffix(".0", "") + |> Kernel.<>("k") + end + + defp fmt_hits(n), do: Integer.to_string(n) + + defp comma(n) when is_integer(n) do + n + |> Integer.to_string() + |> String.reverse() + |> String.replace(~r/(\d{3})(?=\d)/, "\\1,") + |> String.reverse() + end + + defp stamp do + {{y, mo, d}, {h, mi, _}} = :calendar.universal_time() + + :io_lib.format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B UTC", [y, mo, d, h, mi]) + |> IO.iodata_to_binary() + end + + defp escape(str) do str |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) end + + # ---------------------------------------------------------------------- + # Lightweight, escape-safe Elixir syntax highlighter + # (small, conservative tints: keyword / string / atom / comment / fn-name) + # ---------------------------------------------------------------------- + + @keywords ~w(def defp defmodule defmacro defmacrop defguard defguardp defstruct + defexception defprotocol defimpl defdelegate do end fn when case cond + with if unless else for receive try catch rescue after import alias + require use quote unquote raise throw true false nil and or not in) + + @def_keywords ~w(def defp defmacro defmacrop defguard defguardp) + + @re_string ~r/\A"(?:[^"\\]|\\.)*"/ + @re_atom ~r/\A:[a-zA-Z_][a-zA-Z0-9_]*[?!]?/ + @re_number ~r/\A\d[\d_]*(?:\.\d+)?/ + @re_ident ~r/\A[A-Za-z_][a-zA-Z0-9_]*[?!]?/ + @re_space ~r/\A[ \t]+/ + + defp highlight(line), do: line |> tokenize(:start, []) |> IO.iodata_to_binary() + + defp tokenize("", _state, acc), do: Enum.reverse(acc) + + defp tokenize(rest, state, acc) do + case token(rest) do + {:comment, t, r} -> tokenize(r, :other, [span("c", t) | acc]) + {:string, t, r} -> tokenize(r, :other, [span("s", t) | acc]) + {:atom, t, r} -> tokenize(r, :other, [span("a", t) | acc]) + {:number, t, r} -> tokenize(r, :other, [escape(t) | acc]) + {:space, t, r} -> tokenize(r, state, [escape(t) | acc]) + {:ident, t, r} -> tokenize(r, ident_state(t), [ident_html(t, state) | acc]) + {:char, t, r} -> tokenize(r, :other, [escape(t) | acc]) + end + end + + defp token("#" <> _ = rest), do: {:comment, rest, ""} + + defp token(rest) do + cond do + m = run(@re_string, rest) -> {:string, m, drop(rest, m)} + m = run(@re_atom, rest) -> {:atom, m, drop(rest, m)} + m = run(@re_number, rest) -> {:number, m, drop(rest, m)} + m = run(@re_ident, rest) -> {:ident, m, drop(rest, m)} + m = run(@re_space, rest) -> {:space, m, drop(rest, m)} + true -> next_char(rest) + end + end + + defp ident_state(t) when t in @def_keywords, do: :after_def + defp ident_state(_), do: :other + + defp ident_html(t, state) do + cond do + t in @keywords -> span("k", t) + state == :after_def -> span("fn", t) + true -> escape(t) + end + end + + defp run(re, s) do + case Regex.run(re, s) do + [m | _] -> m + _ -> nil + end + end + + defp drop(s, m), do: binary_part(s, byte_size(m), byte_size(s) - byte_size(m)) + + defp next_char(rest) do + {g, r} = String.next_grapheme(rest) + {:char, g, r} + end + + defp span(class, text), do: [~s(), escape(text), ""] + + # ---------------------------------------------------------------------- + # Inlined static assets + # ---------------------------------------------------------------------- + + defp styles do + ~S""" + :root { + --bg: #f8fafc; --bg-2: #f1f5f9; --bg-3: #e2e8f0; + --fg: #0f172a; --fg-2: #1e293b; --fg-3: #475569; --fg-4: #64748b; --fg-5: #94a3b8; + --border: #e2e8f0; --border-2: #cbd5e1; + --shadow: 0 1px 0 rgba(15,23,42,0.04), 0 1px 2px rgba(15,23,42,0.04); + + /* Per-line heat: ×0 is the only bad state; ×1+ darkens green with hits. */ + --line-nil: transparent; + --line-0-bg: #fee2e2; --line-0-num: #b91c1c; + --line-1-bg: #f0fdf4; --line-2-bg: #dcfce7; --line-3-bg: #bbf7d0; + --line-4-bg: #86efac; --line-5-bg: #4ade80; --line-cov-bg: #bbf7d0; + + /* File coverage ramp — bar fill + pct text */ + --cov-100: #15803d; --cov-90: #16a34a; --cov-15: #dc2626; + + --syn-kw: #7c3aed; --syn-str: #0369a1; --syn-fn: #0f766e; + --syn-cmt: #94a3b8; --syn-atom: #b45309; + + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #0b1220; --bg-2: #111a2e; --bg-3: #1e293b; + --fg: #e2e8f0; --fg-2: #cbd5e1; --fg-3: #94a3b8; --fg-4: #64748b; --fg-5: #475569; + --border: #1e293b; --border-2: #334155; + --shadow: 0 1px 0 rgba(0,0,0,0.4); + --line-0-bg: #450a0a; --line-0-num: #fca5a5; + --line-1-bg: #052e16; --line-2-bg: #14532d; --line-3-bg: #166534; + --line-4-bg: #15803d; --line-5-bg: #16a34a; --line-cov-bg: #166534; + --syn-kw: #c4b5fd; --syn-str: #7dd3fc; --syn-fn: #5eead4; + --syn-cmt: #64748b; --syn-atom: #fcd34d; + } + } + + * { box-sizing: border-box; } + html { background: var(--bg); } + body { + margin: 0; font-family: var(--font-ui); font-size: 13.5px; line-height: 1.5; + color: var(--fg); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + } + + .app-bar { + position: sticky; top: 0; z-index: 20; background: var(--bg); + border-bottom: 1px solid var(--border); padding: 10px 24px; + display: grid; grid-template-columns: auto 1fr auto; gap: 24px; align-items: center; + } + .brand { + font-family: var(--font-mono); font-weight: 700; font-size: 13px; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg); + display: inline-flex; align-items: center; gap: 8px; + } + .brand .dot { + width: 9px; height: 9px; border-radius: 2px; background: var(--cov-100); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--cov-100) 22%, transparent); + } + .brand .sep { color: var(--fg-5); font-weight: 400; } + .brand .sub { color: var(--fg-3); font-weight: 500; letter-spacing: 0; text-transform: none; } + .run-meta { + color: var(--fg-4); font-size: 12px; display: flex; gap: 14px; align-items: center; + font-variant-numeric: tabular-nums; + } + .run-meta b { color: var(--fg-2); font-weight: 600; } + .run-meta .pipe { color: var(--border-2); } + .totals { display: flex; gap: 4px; align-items: center; } + .metric { + padding: 4px 14px; border-left: 1px solid var(--border); + display: flex; flex-direction: column; align-items: flex-end; + font-variant-numeric: tabular-nums; min-width: 78px; + } + .metric b { font-weight: 600; font-size: 18px; letter-spacing: -0.01em; line-height: 1.1; } + .metric i { + font-style: normal; font-size: 10.5px; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--fg-4); margin-top: 2px; + } + .metric.pct b { font-size: 22px; } + .metric.miss b { color: var(--cov-15); } + .metric.threshold.below b { color: var(--cov-15); } + .metric.threshold.passing b { color: var(--cov-100); } + + main { max-width: 1280px; margin: 0 auto; padding: 18px 24px 64px; } + .toolbar { display: flex; align-items: baseline; gap: 14px; padding: 6px 4px 12px; } + .toolbar h2 { + margin: 0; font-size: 11.5px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--fg-3); + } + .toolbar .count { color: var(--fg-4); font-variant-numeric: tabular-nums; font-size: 12px; } + .toolbar .spacer { flex: 1; } + .copygroup { display: inline-flex; gap: 4px; } + .copybtn { + display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); + font-size: 12px; font-weight: 500; color: var(--fg-2); background: var(--bg); + border: 1px solid var(--border-2); padding: 5px 10px; border-radius: 4px; + cursor: pointer; transition: background 0.12s, border-color 0.12s, color 0.12s; + } + .copybtn:hover { background: var(--bg-2); border-color: var(--fg-5); } + .copybtn:active { background: var(--bg-3); } + .copybtn.copied { + background: color-mix(in oklch, var(--cov-100) 15%, var(--bg)); + border-color: var(--cov-100); color: var(--cov-100); + } + + .index { border: 1px solid var(--border); border-radius: 6px; background: var(--bg); box-shadow: var(--shadow); } + table.modules { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; } + table.modules thead th:first-child { border-top-left-radius: 5px; } + table.modules thead th:last-child { border-top-right-radius: 5px; } + table.modules tbody tr:last-child td:first-child { border-bottom-left-radius: 5px; } + table.modules tbody tr:last-child td:last-child { border-bottom-right-radius: 5px; } + table.modules thead th { + text-align: left; font-size: 10.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--fg-4); padding: 8px 14px; background: var(--bg-2); + border-bottom: 1px solid var(--border); user-select: none; cursor: pointer; + white-space: nowrap; position: sticky; top: var(--appbar-h, 56px); z-index: 1; + } + table.modules thead th .arrow { opacity: 0.4; margin-left: 4px; font-size: 9px; } + table.modules thead th.sorted .arrow { opacity: 1; color: var(--fg-2); } + table.modules thead th.num { text-align: right; } + table.modules thead th.bar-col { width: 160px; } + table.modules tbody td { + padding: 7px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; + font-variant-numeric: tabular-nums; + } + table.modules tbody tr:last-child td { border-bottom: none; } + table.modules tbody tr.row { cursor: pointer; transition: background 0.08s; } + table.modules tbody tr.row:hover td { background: var(--bg-2); } + table.modules tbody tr.row.open td { background: var(--bg-2); } + table.modules tbody tr.row.open td.path { font-weight: 600; } + td.num { text-align: right; } + td.path { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); white-space: nowrap; } + td.path .chev { display: inline-block; width: 10px; color: var(--fg-5); transition: transform 0.12s; margin-right: 4px; } + tr.row.open td.path .chev { transform: rotate(90deg); color: var(--fg-3); } + td.muted { color: var(--fg-4); } + td.pct { font-family: var(--font-mono); font-weight: 600; font-size: 12.5px; width: 60px; } + .bar { position: relative; width: 140px; height: 6px; background: var(--bg-3); border-radius: 3px; overflow: hidden; } + .bar > i { display: block; height: 100%; border-radius: 3px; } + td.maxhit { color: var(--fg-3); font-family: var(--font-mono); font-size: 12px; } + td.maxhit .ghost { color: var(--fg-5); } + td.miss-cell { color: var(--fg-4); } + td.miss-cell.has { color: var(--cov-15); font-weight: 600; } + + tr.source-row { display: none; } + tr.source-row.open { display: table-row; } + tr.source-row > td { padding: 0; background: var(--bg); border-bottom: 1px solid var(--border); } + .source-pane { background: var(--bg); border-top: 1px solid var(--border); } + .source-header { + display: flex; align-items: center; gap: 14px; padding: 8px 16px; background: var(--bg-2); + border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); + } + .source-header .stat { display: inline-flex; align-items: center; gap: 5px; } + .source-header .stat b { color: var(--fg); font-weight: 600; } + .source-header .stat::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--fg-5); } + .source-header .stat.pct::before { background: var(--cov-100); } + .source-header .stat.miss::before { background: var(--cov-15); } + .source-header .stat.maxhit::before { background: var(--syn-kw); } + .source-header .scale { + margin-left: auto; display: inline-flex; align-items: center; border: 1px solid var(--border); + border-radius: 3px; background: var(--bg); padding: 2px; + } + .source-header .scale > span { font-size: 10px; color: var(--fg-3); padding: 2px 6px 2px 8px; } + .source-header .scale > i { display: block; width: 16px; height: 14px; border-radius: 2px; margin: 0 1px; } + + .code { font-family: var(--font-mono); font-size: 12px; line-height: 1.55; background: var(--bg); max-height: 580px; overflow: auto; } + .code .ln { display: grid; grid-template-columns: 54px 1fr; align-items: stretch; } + .code .ln > .num { + text-align: right; padding: 0 12px 0 16px; color: var(--fg-5); background: var(--bg); + border-right: 1px solid var(--border); user-select: none; font-variant-numeric: tabular-nums; position: relative; + } + .code .ln > .src { padding: 0 14px; white-space: pre; color: var(--fg); position: relative; } + .code .ln:hover > .src { box-shadow: inset 0 0 0 9999px rgba(15,23,42,0.025); } + .code .ln.h-nil > .src { background: var(--line-nil); } + .code .ln.h-0 > .src { background: var(--line-0-bg); } + .code .ln.h-1 > .src { background: var(--line-1-bg); } + .code .ln.h-2 > .src { background: var(--line-2-bg); } + .code .ln.h-3 > .src { background: var(--line-3-bg); } + .code .ln.h-4 > .src { background: var(--line-4-bg); } + .code .ln.h-5 > .src { background: var(--line-5-bg); } + .code .ln.h-cov > .src { background: var(--line-cov-bg); } + .code .ln.h-0 > .num { color: var(--line-0-num); font-weight: 600; } + .code .ln.h-nil > .num { color: var(--fg-5); } + .code .ln.h-0 > .num::after, .code .ln.h-1 > .num::after, .code .ln.h-2 > .num::after, + .code .ln.h-3 > .num::after, .code .ln.h-4 > .num::after, .code .ln.h-5 > .num::after { + content: ''; position: absolute; right: -1px; top: 0; bottom: 0; width: 3px; + } + .code .ln.h-0 > .num::after { background: var(--cov-15); } + .code .ln.h-1 > .num::after { background: var(--line-3-bg); } + .code .ln.h-2 > .num::after { background: var(--line-4-bg); } + .code .ln.h-3 > .num::after { background: var(--line-5-bg); } + .code .ln.h-4 > .num::after { background: var(--cov-90); } + .code .ln.h-5 > .num::after { background: var(--cov-100); } + + .k { color: var(--syn-kw); } + .s { color: var(--syn-str); } + .fn { color: var(--syn-fn); } + .c { color: var(--syn-cmt); font-style: italic; } + .a { color: var(--syn-atom); } + + .fn-range { position: relative; } + .fn-tip { + position: absolute; left: 60px; top: -36px; background: var(--fg); color: var(--bg); + font-family: var(--font-mono); font-size: 11px; padding: 6px 10px; border-radius: 4px; + white-space: nowrap; pointer-events: none; opacity: 0; transform: translateY(2px); + transition: opacity 0.12s, transform 0.12s; box-shadow: 0 4px 12px rgba(15,23,42,0.18); z-index: 5; + } + .fn-tip::after { + content: ''; position: absolute; left: 16px; bottom: -5px; + border: 5px solid transparent; border-top-color: var(--fg); border-bottom: 0; + } + .fn-tip b { color: #fbbf24; font-weight: 600; } + .fn-tip i { font-style: normal; color: var(--fg-5); margin-left: 6px; font-size: 10.5px; } + .fn-range:hover > .fn-tip { opacity: 1; transform: translateY(0); } + + .source-footer { + padding: 8px 16px; background: var(--bg-2); border-top: 1px solid var(--border); + display: flex; gap: 14px; align-items: center; font-size: 11px; color: var(--fg-4); font-family: var(--font-mono); + } + .source-footer .legend { display: inline-flex; gap: 4px; align-items: center; } + .source-footer .legend > i { + width: 14px; height: 12px; border-radius: 2px; background: var(--line-0-bg); + border: 1px solid color-mix(in oklch, currentColor 18%, transparent); + } + .source-footer .legend.h1 > i { background: var(--line-1-bg); } + .source-footer .legend.h2 > i { background: var(--line-2-bg); } + .source-footer .legend.h3 > i { background: var(--line-3-bg); } + .source-footer .legend.h4 > i { background: var(--line-4-bg); } + .source-footer .legend.h5 > i { background: var(--line-5-bg); } + .source-footer .ramp { margin-left: auto; } + + .ignored { margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border); opacity: 0.55; transition: opacity 0.12s; } + .ignored:hover { opacity: 1; } + .ignored h2 { font-size: 11.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 2px; } + .ignored .count { font-size: 11px; color: var(--fg-5); font-weight: 400; letter-spacing: 0; text-transform: none; } + .ignored .note { margin: 0 0 8px; font-size: 12px; color: var(--fg-4); } + .ignored ul { list-style: none; margin: 0; padding: 0; font-size: 12px; } + .ignored li { padding: 2px 0; } + .ignored .loc { font-family: var(--font-mono); color: var(--fg-3); } + .ignored code { font-family: var(--font-mono); color: var(--fg-2); } + .ignored .tag { color: var(--fg-5); font-size: 11px; } + + .foot { margin-top: 18px; font-size: 11px; color: var(--fg-4); text-align: center; font-family: var(--font-mono); } + + @media (max-width: 900px) { + table.modules thead th.bar-col, table.modules tbody td.bar-col, + table.modules thead th.lines-col, table.modules tbody td.lines-col, + table.modules thead th.rel-col, table.modules tbody td.rel-col { display: none; } + } + """ + end + + defp script do + ~S""" + (function () { + var table = document.getElementById('moduleTable'); + if (!table) return; + var tbody = document.getElementById('moduleBody'); + var ths = Array.prototype.slice.call(table.tHead.rows[0].cells); + + function setAppBarHeight() { + var bar = document.querySelector('.app-bar'); + if (bar) document.documentElement.style.setProperty('--appbar-h', Math.round(bar.getBoundingClientRect().height) + 'px'); + } + setAppBarHeight(); + window.addEventListener('resize', setAppBarHeight); + + tbody.addEventListener('click', function (e) { + var row = e.target.closest('tr.row'); + if (!row) return; + var src = row.nextElementSibling; + if (src && src.classList.contains('source-row')) { + var open = src.classList.toggle('open'); + row.classList.toggle('open', open); + } + }); + + function sortBy(key, dir) { + var idx = ths.findIndex(function (th) { return th.dataset.sort === key; }); + if (idx < 0) return; + var pairs = []; + Array.prototype.forEach.call(tbody.rows, function (r) { + if (r.classList.contains('row')) pairs.push([r]); + else if (pairs.length) pairs[pairs.length - 1].push(r); + }); + pairs.sort(function (a, b) { + var va = a[0].cells[idx].dataset.val, vb = b[0].cells[idx].dataset.val; + var na = parseFloat(va), nb = parseFloat(vb), cmp; + if (!isNaN(na) && !isNaN(nb)) cmp = na - nb; + else cmp = String(va).localeCompare(String(vb)); + return dir < 0 ? -cmp : cmp; + }); + pairs.forEach(function (p) { p.forEach(function (r) { tbody.appendChild(r); }); }); + ths.forEach(function (th) { + var on = th.dataset.sort === key; + th.classList.toggle('sorted', on); + var arr = th.querySelector('.arrow'); + if (arr) arr.textContent = on ? (dir < 0 ? '↓' : '↑') : '↕'; + }); + location.hash = 'sort=' + key + ',' + (dir < 0 ? 'desc' : 'asc'); + } + + var curKey = 'pct', curDir = 1; + ths.forEach(function (th) { + if (!th.dataset.sort) return; + th.addEventListener('click', function () { + var k = th.dataset.sort; + if (k === curKey) curDir = -curDir; + else { curKey = k; curDir = (k === 'pct' || k === 'missed' || k === 'path') ? 1 : -1; } + sortBy(curKey, curDir); + }); + }); + + var m = location.hash.match(/sort=([^,]+),(asc|desc)/); + if (m) { curKey = m[1]; curDir = m[2] === 'desc' ? -1 : 1; sortBy(curKey, curDir); } + + var INDEX = JSON.parse(document.getElementById('six-index').textContent); + var TOTAL = JSON.parse(document.getElementById('six-total').textContent); + var COLS = [ + { k: 'path', label: 'file', w: 34, align: 'l' }, + { k: 'pct', label: 'cov%', w: 6, align: 'r', f: function (v) { return v.toFixed(1); } }, + { k: 'lines', label: 'lines', w: 6, align: 'r' }, + { k: 'relevant', label: 'relevant', w: 9, align: 'r' }, + { k: 'missed', label: 'missed', w: 7, align: 'r' }, + { k: 'max', label: 'max', w: 9, align: 'r', f: function (v) { return v.toLocaleString(); } } + ]; + + function totalLine() { + return 'six coverage · ' + TOTAL.pct.toFixed(1) + '% · ' + TOTAL.covered + '/' + TOTAL.relevant + ' relevant · ' + TOTAL.missed + ' missed'; + } + function fmtMd() { + var head = '| ' + COLS.map(function (c) { return c.label; }).join(' | ') + ' |'; + var sep = '|' + COLS.map(function (c) { return c.align === 'r' ? '---:' : '---'; }).join('|') + '|'; + var rows = INDEX.map(function (r) { + return '| ' + COLS.map(function (c) { + var v = c.f ? c.f(r[c.k]) : r[c.k]; + return c.k === 'path' ? '`' + v + '`' : v; + }).join(' | ') + ' |'; + }); + return ['**' + totalLine() + '**', '', head, sep].concat(rows).join('\n'); + } + function pad(s, w, a) { s = String(s); return a === 'r' ? s.padStart(w) : s.padEnd(w); } + function fmtAscii() { + var sep = COLS.map(function (c) { return '─'.repeat(c.w); }).join(' '); + var header = COLS.map(function (c) { return pad(c.label, c.w, c.align); }).join(' '); + var body = INDEX.map(function (r) { + return COLS.map(function (c) { return pad(c.f ? c.f(r[c.k]) : r[c.k], c.w, c.align); }).join(' '); + }); + return [totalLine(), sep, header, sep].concat(body, [sep]).join('\n'); + } + function copyText(txt) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(txt); + return new Promise(function (resolve, reject) { + var ta = document.createElement('textarea'); + ta.value = txt; ta.style.position = 'fixed'; ta.style.top = '-1000px'; + document.body.appendChild(ta); ta.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(); + }); + } + Array.prototype.forEach.call(document.querySelectorAll('.copybtn'), function (btn) { + btn.addEventListener('click', function () { + var txt = btn.dataset.copy === 'md' ? fmtMd() : fmtAscii(); + var prev = btn.textContent; + copyText(txt).then(function () { btn.textContent = 'copied'; }, function () { btn.textContent = 'copy failed'; }) + .then(function () { setTimeout(function () { btn.textContent = prev; }, 1400); }); + }); + }); + })(); + """ + end end diff --git a/lib/six/heatmap.ex b/lib/six/heatmap.ex new file mode 100644 index 0000000..b0b31ca --- /dev/null +++ b/lib/six/heatmap.ex @@ -0,0 +1,48 @@ +defmodule Six.Heatmap do + @moduledoc false + + # Maps a per-line hit count to a heat bucket. Buckets are fixed + # order-of-magnitude decades so they're comparable across modules: + # + # nil uninstrumented (filtered or non-code) + # :cold instrumented but never hit (×0) + # 1 ×1 (covered, but barely — a "weak spot") + # 2 ×2–9 + # 3 ×10–99 + # 4 ×100–999 + # 5 ×1000+ (hottest) + + @type bucket :: nil | :cold | 1..5 + + @doc """ + Returns the heat bucket for a single line's hit count. + """ + @spec bucket(nil | non_neg_integer()) :: bucket() + def bucket(nil), do: nil + def bucket(0), do: :cold + def bucket(1), do: 1 + def bucket(n) when is_integer(n) and n < 10, do: 2 + def bucket(n) when is_integer(n) and n < 100, do: 3 + def bucket(n) when is_integer(n) and n < 1000, do: 4 + def bucket(n) when is_integer(n), do: 5 + + @doc """ + Number of "weak-spot" lines — instrumented lines hit exactly once. + """ + @spec cold_lines([nil | non_neg_integer()]) :: non_neg_integer() + def cold_lines(coverage) do + Enum.count(coverage, &(&1 == 1)) + end + + @doc """ + The largest hit count across a coverage array (0 when none). + """ + @spec max_hits([nil | non_neg_integer()]) :: non_neg_integer() + def max_hits(coverage) do + coverage + |> Enum.reduce(0, fn + n, acc when is_integer(n) and n > acc -> n + _, acc -> acc + end) + end +end diff --git a/lib/six/ignore.ex b/lib/six/ignore.ex index ea2e785..e94d9ac 100644 --- a/lib/six/ignore.ex +++ b/lib/six/ignore.ex @@ -124,7 +124,12 @@ defmodule Six.Ignore do defp directives_by_line(source) do source_lines = String.split(source, "\n") - case Code.string_to_quoted_with_comments(source, columns: true, token_metadata: true) do + {parsed, _diagnostics} = + Code.with_diagnostics(fn -> + Code.string_to_quoted_with_comments(source, columns: true, token_metadata: true) + end) + + case parsed do {:ok, _ast, comments} -> comments |> Enum.reduce(%{}, fn %{line: line, text: text, column: column}, acc -> diff --git a/lib/six/ignore_functions.ex b/lib/six/ignore_functions.ex index ea2a5c0..27770ca 100644 --- a/lib/six/ignore_functions.ex +++ b/lib/six/ignore_functions.ex @@ -34,18 +34,27 @@ defmodule Six.Ignore.Functions do @doc """ Returns function definitions in source order. - Each entry includes `:function`, `:start_line`, `:end_line`, and `:ignored?`. + Each entry includes `:module`, `:function`, `:arity`, `:start_line`, `:end_line`, and `:ignored?`. """ def functions(source, attribute_name \\ :six) do source |> parse_functions(attribute_name) |> Enum.map(fn %{ + module: module, function: function, + arity: arity, start_line: start_line, end_line: end_line, ignored?: ignored? } -> - %{function: function, start_line: start_line, end_line: end_line, ignored?: ignored?} + %{ + module: module, + function: function, + arity: arity, + start_line: start_line, + end_line: end_line, + ignored?: ignored? + } end) end @@ -81,13 +90,17 @@ defmodule Six.Ignore.Functions do end defp parse_functions_via_ast(source, attribute_name) do - case Code.string_to_quoted(source, columns: true, token_metadata: true) do + # Suppress diagnostics (e.g. deprecation warnings) emitted while parsing + # user source for analysis — the compiler already surfaced them at build. + {parsed, _diagnostics} = + Code.with_diagnostics(fn -> + Code.string_to_quoted(source, columns: true, token_metadata: true) + end) + + case parsed do {:ok, ast} -> source_lines = String.split(source, "\n") - - ast - |> extract_body() - |> scan_expressions(attribute_name, source_lines, false, []) + scan_ast(ast, attribute_name, source_lines, nil, false, []) {:error, _} -> parse_functions_via_string(source, attribute_name) @@ -107,44 +120,77 @@ defmodule Six.Ignore.Functions do @six :ignore defp extract_body(_), do: [] - defp scan_expressions([], _attr_name, _source_lines, _ignore_next, acc), do: Enum.reverse(acc) + defp scan_ast( + {:defmodule, _, [name_ast, kwl]} = expr, + attr_name, + source_lines, + _module, + _ignore_next, + acc + ) + when is_list(kwl) do + module = module_name(name_ast) + expr |> extract_body() |> scan_expressions(attr_name, source_lines, module, false, acc) + end + + defp scan_ast({:__block__, _, body}, attr_name, source_lines, module, ignore_next, acc) + when is_list(body) do + scan_expressions(body, attr_name, source_lines, module, ignore_next, acc) + end + + defp scan_ast(ast, attr_name, source_lines, module, ignore_next, acc) do + scan_expressions([ast], attr_name, source_lines, module, ignore_next, acc) + end + + defp scan_expressions([], _attr_name, _source_lines, _module, _ignore_next, acc), + do: Enum.reverse(acc) - defp scan_expressions([expr | rest], attr_name, source_lines, ignore_next, acc) do + defp scan_expressions([expr | rest], attr_name, source_lines, module, ignore_next, acc) do case expr do {:@, _, [{^attr_name, _, [:ignore]}]} -> - scan_expressions(rest, attr_name, source_lines, true, acc) + scan_expressions(rest, attr_name, source_lines, module, true, acc) {def_type, meta, [head | _]} when def_type in @def_keywords -> entry = %{ + module: module, function: format_function(def_type, head), + arity: head_arity(head), start_line: meta[:line], end_line: find_end_line(meta, source_lines, meta[:line]), ignored?: ignore_next } - scan_expressions(rest, attr_name, source_lines, false, [entry | acc]) + scan_expressions(rest, attr_name, source_lines, module, false, [entry | acc]) - {:defmodule, _, [_, kwl]} when is_list(kwl) -> + {:defmodule, _, [name_ast, kwl]} when is_list(kwl) -> + nested_module = module_name(name_ast) inner_body = extract_body(expr) - inner_entries = scan_expressions(inner_body, attr_name, source_lines, false, []) + + inner_entries = + scan_expressions(inner_body, attr_name, source_lines, nested_module, false, []) scan_expressions( rest, attr_name, source_lines, + module, ignore_next, Enum.reverse(inner_entries) ++ acc ) _ -> if is_module_attribute?(expr) and ignore_next do - scan_expressions(rest, attr_name, source_lines, true, acc) + scan_expressions(rest, attr_name, source_lines, module, true, acc) else - scan_expressions(rest, attr_name, source_lines, false, acc) + scan_expressions(rest, attr_name, source_lines, module, false, acc) end end end + defp module_name({:__aliases__, _, parts}), do: Module.concat(parts) + defp module_name(atom) when is_atom(atom), do: atom + defp module_name(_), do: nil + defp is_module_attribute?({:@, _, _}), do: true defp is_module_attribute?(_), do: false @@ -159,6 +205,11 @@ defmodule Six.Ignore.Functions do defp extract_function_name({name, _, _args}) when is_atom(name), do: Atom.to_string(name) defp extract_function_name(_), do: nil + defp head_arity({:when, _, [call | _guards]}), do: head_arity(call) + defp head_arity({name, _, args}) when is_atom(name) and is_list(args), do: length(args) + defp head_arity({name, _, nil}) when is_atom(name), do: 0 + defp head_arity(_), do: nil + @six :ignore defp find_end_line(meta, source_lines, start_line) do cond do @@ -248,7 +299,17 @@ defmodule Six.Ignore.Functions do end_line = find_function_end(lines, line_num) {false, - [%{function: function, start_line: line_num, end_line: end_line, ignored?: true} | acc]} + [ + %{ + module: nil, + function: function, + arity: nil, + start_line: line_num, + end_line: end_line, + ignored?: true + } + | acc + ]} ignore_next && Regex.match?(~r/^\s*@/, line) -> {true, acc} diff --git a/lib/six/report.ex b/lib/six/report.ex index 3da78ba..0e6f1f4 100644 --- a/lib/six/report.ex +++ b/lib/six/report.ex @@ -12,7 +12,8 @@ defmodule Six.Report do output_dir: config.output_dir, detail: config.detail, filter: config.filter, - threshold: config.threshold + threshold: config.threshold, + heatmap: config.heatmap ] Enum.each(config.formatters, fn formatter -> @@ -27,9 +28,11 @@ defmodule Six.Report do end defp build_summary(config) do + function_calls = Six.Cover.analyze_all_functions() + file_stats = Six.Cover.analyze_all() - |> Six.Stats.build() + |> Six.Stats.build(function_calls) |> Six.Stats.skip_files(config.skip_files) |> Six.Filter.run(config) |> apply_comment_ignores() diff --git a/lib/six/stats.ex b/lib/six/stats.ex index 5844e69..2bc1d33 100644 --- a/lib/six/stats.ex +++ b/lib/six/stats.ex @@ -7,10 +7,13 @@ defmodule Six.Stats do path: String.t(), source: String.t(), coverage: [line_coverage()], + function_calls: %{{module(), atom(), non_neg_integer()} => non_neg_integer()}, lines: non_neg_integer(), relevant: non_neg_integer(), covered: non_neg_integer(), missed: non_neg_integer(), + cold_lines: non_neg_integer(), + max_hits: non_neg_integer(), percentage: float() } @@ -20,15 +23,24 @@ defmodule Six.Stats do total_relevant: non_neg_integer(), total_covered: non_neg_integer(), total_missed: non_neg_integer(), + total_cold_lines: non_neg_integer(), + project_max_hits: non_neg_integer(), percentage: float() } @doc """ Builds per-file stats from raw cover data. - cover_data is a map of %{module => [{{module, line}, count}]}. + + `line_data` is a map of `%{module => [{{module, line}, count}]}`. + `function_data` is a map of `%{module => [{{module, fun, arity}, count}]}`. + + Call counts for the same source line (when multiple modules compile into + one file) are summed, not OR-ed — heat depends on the true total. """ - def build(cover_data) do - cover_data + def build(line_data, function_data \\ %{}) do + function_maps = build_function_maps(function_data) + + line_data |> Enum.reduce(%{}, fn {module, results}, acc -> case Six.Cover.module_path(module) do nil -> @@ -39,16 +51,34 @@ defmodule Six.Stats do Map.update(acc, path, module_cover_map, fn cover_map -> Map.merge(cover_map, module_cover_map, fn _line, existing, current -> - max(existing, current) + existing + current end) end) end end) - |> Enum.map(fn {path, cover_map} -> build_file_stats(path, cover_map) end) + |> Enum.map(fn {path, cover_map} -> + build_file_stats(path, cover_map, Map.get(function_maps, path, %{})) + end) |> Enum.sort_by(& &1.path) end - defp build_file_stats(path, cover_map) do + defp build_function_maps(function_data) do + Enum.reduce(function_data, %{}, fn {module, results}, acc -> + case Six.Cover.module_path(module) do + nil -> + acc + + path -> + fun_map = results_to_function_map(module, results) + + Map.update(acc, path, fun_map, fn existing -> + Map.merge(existing, fun_map, fn _key, a, b -> a + b end) + end) + end + end) + end + + defp build_file_stats(path, cover_map, function_calls) do source = File.read!(path) source_lines = String.split(source, "\n") total_lines = length(source_lines) @@ -58,26 +88,26 @@ defmodule Six.Stats do Map.get(cover_map, i, nil) end - relevant = Enum.count(coverage, &(&1 != nil)) - covered = Enum.count(coverage, &(&1 != nil && &1 > 0)) - missed = relevant - covered - %{ path: path, source: source, coverage: coverage, - lines: total_lines, - relevant: relevant, - covered: covered, - missed: missed, - percentage: calc_percentage(covered, relevant) + function_calls: function_calls, + lines: total_lines } + |> recalculate() end defp results_to_cover_map(results) do Map.new(results, fn {{_mod, line}, count} -> {line, count} end) end + defp results_to_function_map(module, results) do + Enum.reduce(results, %{}, fn {{_mod, fun, arity}, count}, acc -> + Map.update(acc, {module, fun, arity}, count, &(&1 + count)) + end) + end + @doc """ Aggregates file stats into a summary. """ @@ -86,6 +116,10 @@ defmodule Six.Stats do total_relevant = Enum.sum(Enum.map(file_stats_list, & &1.relevant)) total_covered = Enum.sum(Enum.map(file_stats_list, & &1.covered)) total_missed = total_relevant - total_covered + total_cold_lines = Enum.sum(Enum.map(file_stats_list, &Map.get(&1, :cold_lines, 0))) + + project_max_hits = + file_stats_list |> Enum.map(&Map.get(&1, :max_hits, 0)) |> Enum.max(fn -> 0 end) %{ files: file_stats_list, @@ -93,6 +127,8 @@ defmodule Six.Stats do total_relevant: total_relevant, total_covered: total_covered, total_missed: total_missed, + total_cold_lines: total_cold_lines, + project_max_hits: project_max_hits, percentage: calc_percentage(total_covered, total_relevant) } end @@ -121,13 +157,15 @@ defmodule Six.Stats do covered = Enum.count(coverage, &(&1 != nil && &1 > 0)) missed = relevant - covered - %{ - file_stats - | relevant: relevant, - covered: covered, - missed: missed, - percentage: calc_percentage(covered, relevant) - } + file_stats + |> Map.merge(%{ + relevant: relevant, + covered: covered, + missed: missed, + cold_lines: Six.Heatmap.cold_lines(coverage), + max_hits: Six.Heatmap.max_hits(coverage), + percentage: calc_percentage(covered, relevant) + }) end defp calc_percentage(_covered, 0), do: 100.0 diff --git a/test/config_test.exs b/test/config_test.exs index d53379e..dbe085c 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -58,6 +58,11 @@ defmodule Six.ConfigTest do assert updated.threshold == 75 end + test "heatmap defaults on and can be overridden" do + assert Config.read().heatmap == true + assert Config.merge_with_opts(Config.read(), heatmap: false).heatmap == false + end + test "merge_with_opts accumulates skip patterns" do config = Config.read() updated = Config.merge_with_opts(config, skip: "generated/") diff --git a/test/formatters/html_test.exs b/test/formatters/html_test.exs index 4419678..ef9a961 100644 --- a/test/formatters/html_test.exs +++ b/test/formatters/html_test.exs @@ -4,78 +4,302 @@ defmodule Six.Formatters.HTMLTest do alias Six.Formatters.HTML - defp sample_summary do + defp mkfile(path, source, coverage, opts \\ []) do + relevant = Enum.count(coverage, &(&1 != nil)) + covered = Enum.count(coverage, &(&1 != nil and &1 > 0)) + %{ - files: [ - %{ - path: "lib/a.ex", - source: "def foo, do: :ok\ndef bar, do: :fail\n# comment", - coverage: [1, 0, nil], - lines: 3, - relevant: 2, - covered: 1, - missed: 1, - percentage: 50.0 - } - ], - total_lines: 3, - total_relevant: 2, - total_covered: 1, - total_missed: 1, - percentage: 50.0 + path: path, + source: source, + coverage: coverage, + function_calls: Keyword.get(opts, :fc, %{}), + lines: length(String.split(source, "\n")), + relevant: relevant, + covered: covered, + missed: relevant - covered, + cold_lines: Enum.count(coverage, &(&1 == 1)), + max_hits: Keyword.get(opts, :max, Six.Heatmap.max_hits(coverage)), + percentage: Keyword.get(opts, :pct, pct(covered, relevant)) } end - test "format writes HTML file to disk" do - dir = System.tmp_dir!() |> Path.join("six_html_test_#{System.unique_integer([:positive])}") + defp pct(_covered, 0), do: 100.0 + defp pct(covered, relevant), do: Float.floor(covered / relevant * 100, 1) + + defp mksummary(files) do + %{ + files: files, + total_lines: Enum.sum(Enum.map(files, & &1.lines)), + total_relevant: Enum.sum(Enum.map(files, & &1.relevant)), + total_covered: Enum.sum(Enum.map(files, & &1.covered)), + total_missed: Enum.sum(Enum.map(files, & &1.missed)), + total_cold_lines: Enum.sum(Enum.map(files, & &1.cold_lines)), + project_max_hits: files |> Enum.map(& &1.max_hits) |> Enum.max(fn -> 0 end), + percentage: 80.0 + } + end + + defp render(summary, opts \\ []) do + dir = System.tmp_dir!() |> Path.join("six_html_#{System.unique_integer([:positive])}") + File.rm_rf!(dir) + capture_io(fn -> HTML.format(summary, Keyword.put(opts, :output_dir, dir)) end) + content = File.read!(Path.join(dir, "coverage.html")) File.rm_rf!(dir) + content + end - capture_io(fn -> - HTML.format(sample_summary(), output_dir: dir) - end) + @demo """ + defmodule Demo do + # comment line + @attr "string" + def build(x) do + n = x + 123 + {:ok, n} + end + + def merge(a, b) do + a + b + end + end + """ + + @adj "def a, do: 1\ndef b, do: 2" + + @fb """ + defmodule Fb do + def calc(x) do + y = x + y + end + end + """ + + @dyn """ + defmodule Dyn do + def unquote(:go)(), do: :ok + end + """ + + defp big_summary do + mksummary([ + mkfile("lib/demo.ex", @demo, [nil, nil, nil, 1843, 412, 1, 89, nil, 9, 0, 9, nil, nil], + fc: %{{Demo, :build, 1} => 1843, {Demo, :merge, 2} => 9}, + max: 24_108, + pct: 80.0 + ), + mkfile("lib/adj.ex", @adj, [5, 5], max: 1843), + mkfile("lib/fb.ex", @fb, [nil, 2_000_000, 0, nil, 5, nil, nil], max: 2_000_000), + mkfile("lib/dyn.ex", @dyn, [nil, 5, nil, nil], max: 5), + mkfile("lib/tiny.ex", "defmodule Tiny do\n def t, do: 1\nend", [nil, 122, nil, nil], + max: 122 + ), + mkfile("lib/walker.ex", "defmodule Walker do\n def w, do: :x\nend", [nil, 0, nil, nil], + pct: 0.0 + ) + ]) + end - path = Path.join(dir, "coverage.html") - assert File.exists?(path) + test "writes a self-contained, offline HTML file" do + content = render(big_summary()) - content = File.read!(path) assert content =~ "" - assert content =~ "Six Coverage Report" - assert content =~ "lib/a.ex" - assert content =~ "50.0%" + assert content =~ ~s(class="app-bar") + assert content =~ "six" + assert content =~ "coverage" + refute content =~ " Path.join("six_html_test_#{System.unique_integer([:positive])}") - File.rm_rf!(dir) + test "function tooltips show name/arity and call counts" do + content = render(big_summary()) - capture_io(fn -> - HTML.format(sample_summary(), output_dir: dir) - end) + assert content =~ ~s(class="fn-range") + assert content =~ ~s(class="fn-tip") + # count from function_calls + assert content =~ "demo.build/1" + assert content =~ "×1,843 calls" + # count derived from line coverage when function_calls is empty + assert content =~ "fb.calc/1" + assert content =~ "×2,000,000 calls" + # arity-less (metaprogrammed) head renders name-only + assert content =~ "dyn.def" + end - content = File.read!(Path.join(dir, "coverage.html")) - assert content =~ "class=\"hit\"" - assert content =~ "class=\"miss\"" - # Nil coverage lines should have no class - assert content =~ "
    2, {Two, :shared, 0} => 5} + ) + ]) + + content = render(summary) + + assert content =~ "multi.shared/0 ×2 calls" + assert content =~ "multi.shared/0 ×5 calls" + refute content =~ "×7 calls" end - test "output_path returns default path" do - assert HTML.output_path([]) == ".six/coverage.html" + test "function tooltips still support legacy unqualified call maps" do + source = """ + defmodule Legacy do + def legacy, do: :ok + end + """ + + summary = + mksummary([ + mkfile("lib/legacy.ex", source, [nil, 3, nil, nil], fc: %{{:legacy, 0} => 3}) + ]) + + content = render(summary) + + assert content =~ "legacy.legacy/0 ×3 calls" end - test "output_path respects output_dir option" do + test "render shows threshold and marks below-threshold totals" do + content = render(big_summary(), threshold: 90) + + assert content =~ ~s(class="metric threshold below") + assert content =~ "90.0%threshold" + end + + test "embedded index JSON escapes script-breaking path characters" do + source = "defmodule Safe do\n def ok, do: :ok\nend" + + path = + "lib/bad&name\\line\nreturn\rtab\tback\bform\f.ex" + + content = render(mksummary([mkfile(path, source, [nil, 1, nil])])) + + assert content =~ "bad\\u003c/script\\u003e\\u003cscript\\u003ealert" + assert content =~ "\\u0026name\\\\line\\nreturn\\rtab\\tback\\bform\\f.ex" + refute content =~ path + end + + test "syntax highlighter tags keywords, strings, atoms, comments, fn names" do + content = render(big_summary()) + + for cls <- ~w(k s a c fn) do + assert content =~ ~s(), "expected token class #{cls}" + end + end + + test "coverage ramp colors the bar and pct text" do + content = render(big_summary()) + # 100% file → deep green; 0% file → deepest red + assert content =~ "#15803d" + assert content =~ "#991b1b" + end + + test "max-hit column abbreviates and shows a ghost dash at zero" do + content = render(big_summary()) + + assert content =~ ">24k<" + assert content =~ ">1.8k<" + assert content =~ ">122<" + assert content =~ ~s() + end + + test "tiny coverage clamps the bar width" do + content = render(big_summary()) + assert content =~ "width:1.2%" + end + + test "missed cells flag files with gaps" do + content = render(big_summary()) + assert content =~ ~s(class="num miss-cell has") + assert content =~ ~s(class="num miss-cell") + end + + test "source pane shows scale and footer legend with heatmap on" do + content = render(big_summary()) + assert content =~ ~s(class="scale") + assert content =~ "hover any function" + end + + test "heatmap: false renders binary classes and drops heat chrome" do + summary = mksummary([mkfile("lib/x.ex", "defmodule X do\n def a, do: 1\nend", [nil, 5, 0])]) + content = render(summary, heatmap: false) + + assert content =~ "h-cov" + assert content =~ "h-0" + assert content =~ "h-nil" + refute content =~ ~s(class="fn-tip") + refute content =~ ~s(class="scale") + end + + test "ignored section lists comment directives and @six attributes, dimmed" do + source = """ + defmodule Ign do + # six:ignore:next + def a, do: 1 + + # six:ignore:start + def b, do: 2 + # six:ignore:stop + + @six :ignore + def c, do: 3 + end + """ + + content = render(mksummary([mkfile("lib/ign.ex", source, List.duplicate(nil, 11))])) + + assert content =~ "Explicitly ignored" + assert content =~ ~s(class="ignored") + assert content =~ "six:ignore:next" + assert content =~ "six:ignore:start/stop" + assert content =~ "@six :ignore" + end + + test "no ignored section when there are no exclusions" do + content = + render( + mksummary([mkfile("lib/clean.ex", "defmodule C do\n def a, do: 1\nend", [nil, 1, nil])]) + ) + + refute content =~ "Explicitly ignored" + end + + test "parsing source with deprecated syntax emits no warnings" do + # The literal charlist below is inside an Elixir string, so it only gets + # parsed (and would warn) when the formatter analyzes it. + summary = + mksummary([ + mkfile("lib/legacy.ex", "defmodule L do\n def g, do: 'hi'\nend", [nil, 1, nil]) + ]) + + stderr = capture_io(:stderr, fn -> render(summary) end) + assert stderr == "" + end + + test "output_path returns default and respects output_dir" do + assert HTML.output_path([]) == ".six/coverage.html" assert HTML.output_path(output_dir: "custom") == "custom/coverage.html" end test "format with default opts writes to .six/" do - capture_io(fn -> - HTML.format(sample_summary()) - end) - + capture_io(fn -> HTML.format(big_summary()) end) assert File.exists?(".six/coverage.html") end end diff --git a/test/heatmap_test.exs b/test/heatmap_test.exs new file mode 100644 index 0000000..36e9fc2 --- /dev/null +++ b/test/heatmap_test.exs @@ -0,0 +1,29 @@ +defmodule Six.HeatmapTest do + use ExUnit.Case + + alias Six.Heatmap + + test "bucket maps counts to decade buckets" do + assert Heatmap.bucket(nil) == nil + assert Heatmap.bucket(0) == :cold + assert Heatmap.bucket(1) == 1 + assert Heatmap.bucket(9) == 2 + assert Heatmap.bucket(10) == 3 + assert Heatmap.bucket(99) == 3 + assert Heatmap.bucket(100) == 4 + assert Heatmap.bucket(999) == 4 + assert Heatmap.bucket(1000) == 5 + assert Heatmap.bucket(50_000) == 5 + end + + test "cold_lines counts lines hit exactly once" do + assert Heatmap.cold_lines([nil, 0, 1, 1, 2, 100]) == 2 + assert Heatmap.cold_lines([nil, nil]) == 0 + end + + test "max_hits returns the largest count, ignoring nil" do + assert Heatmap.max_hits([nil, 0, 12, 4, nil]) == 12 + assert Heatmap.max_hits([nil, nil]) == 0 + assert Heatmap.max_hits([]) == 0 + end +end diff --git a/test/ignore_functions_test.exs b/test/ignore_functions_test.exs index b71da47..e58a09d 100644 --- a/test/ignore_functions_test.exs +++ b/test/ignore_functions_test.exs @@ -239,6 +239,28 @@ defmodule Six.Ignore.FunctionsTest do assert function.ignored? == false end + test "functions records atom module names" do + source = """ + defmodule :atom_named do + def visible, do: :ok + end + """ + + [function] = Functions.functions(source) + assert function.module == :atom_named + end + + test "functions records nil for dynamic module names" do + source = """ + defmodule unquote(:DynamicName) do + def visible, do: :ok + end + """ + + [function] = Functions.functions(source) + assert function.module == nil + end + test "find_function_end scans for matching end" do lines = [ " def foo do", diff --git a/test/stats_test.exs b/test/stats_test.exs index 9a6766d..b027d06 100644 --- a/test/stats_test.exs +++ b/test/stats_test.exs @@ -132,8 +132,6 @@ defmodule Six.StatsTest do end test "build merges multiple modules from the same source file" do - Code.compile_file("test/fixtures/multi_module.ex") - cover_data = %{ Six.Fixtures.MultiModuleFirst => [ {{Six.Fixtures.MultiModuleFirst, 2}, 1}, @@ -151,7 +149,7 @@ defmodule Six.StatsTest do [file] = result assert file.path == "test/fixtures/multi_module.ex" - assert Enum.at(file.coverage, 1) == 3 + assert Enum.at(file.coverage, 1) == 4 assert Enum.at(file.coverage, 5) == 2 end @@ -164,6 +162,25 @@ defmodule Six.StatsTest do assert result == [] end + test "build keeps function call counts per module in one file" do + line_data = %{ + Six.Fixtures.MultiModuleFirst => [{{Six.Fixtures.MultiModuleFirst, 2}, 1}] + } + + function_data = %{ + Six.Fixtures.MultiModuleFirst => [{{Six.Fixtures.MultiModuleFirst, :shared, 0}, 2}], + Six.Fixtures.MultiModuleSecond => [{{Six.Fixtures.MultiModuleSecond, :shared, 0}, 5}], + # no source path — should be skipped + NonExistentModule => [{{NonExistentModule, :gone, 0}, 9}] + } + + [file] = Stats.build(line_data, function_data) + + assert file.function_calls[{Six.Fixtures.MultiModuleFirst, :shared, 0}] == 2 + assert file.function_calls[{Six.Fixtures.MultiModuleSecond, :shared, 0}] == 5 + refute Map.has_key?(file.function_calls, {NonExistentModule, :gone, 0}) + end + test "skip_files handles non-matching pattern types gracefully" do files = [ %{ diff --git a/test/test_helper.exs b/test/test_helper.exs index f644578..8c4882a 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,6 @@ +# Fixture modules that must be loaded with a real source path (for +# Six.Cover.module_path/1). Compile once here so individual tests don't +# redefine them. +Code.compile_file("test/fixtures/multi_module.ex") + ExUnit.start(exclude: [:coverdata]) From cb76bcae8626784eca657ebf759b40ef70dda4cb Mon Sep 17 00:00:00 2001 From: Thomas Athanas Date: Thu, 28 May 2026 12:48:42 +0000 Subject: [PATCH 2/3] bump version --- README.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d6c2b1..6e26e79 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ def cli do end defp deps do - [{:six, "~> 0.2", only: :test}] + [{:six, "~> 0.3", only: :test}] end ``` diff --git a/mix.exs b/mix.exs index 22b2243..9867134 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Six.MixProject do use Mix.Project - @version "0.2.0" + @version "0.3.0" @source_url "https://github.com/typicalpixel/six" def project do From 124f4bd597b6974f2c54a63e4597f5467ab879a4 Mon Sep 17 00:00:00 2001 From: Thomas Athanas Date: Thu, 28 May 2026 12:54:56 +0000 Subject: [PATCH 3/3] fix footer, ignored font --- lib/six/formatters/html.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/six/formatters/html.ex b/lib/six/formatters/html.ex index c4d65ba..a256bd8 100644 --- a/lib/six/formatters/html.ex +++ b/lib/six/formatters/html.ex @@ -65,7 +65,7 @@ defmodule Six.Formatters.HTML do #{ignored_section(files)} -
    six · generated #{stamp()} · single-file report · works offline
    +
    six · generated #{stamp()}
    @@ -778,8 +778,7 @@ defmodule Six.Formatters.HTML do .source-footer .legend.h5 > i { background: var(--line-5-bg); } .source-footer .ramp { margin-left: auto; } - .ignored { margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border); opacity: 0.55; transition: opacity 0.12s; } - .ignored:hover { opacity: 1; } + .ignored { margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border); } .ignored h2 { font-size: 11.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 2px; } .ignored .count { font-size: 11px; color: var(--fg-5); font-weight: 400; letter-spacing: 0; text-transform: none; } .ignored .note { margin: 0 0 8px; font-size: 12px; color: var(--fg-4); }