defmodule TODO do
@readme File.cwd!() |> Path.join("README.md")
@external_resource @readme
@moduledoc @readme
|> File.read!()
|> String.split("<!-- moduledoc start -->")
|> Enum.at(1)
def config_default(:prod, :persist), do: false
def config_default(_, :persist), do: true
def config_default(_, :print), do: :overdue
def config(key) do
Application.get_env(:todo, key, config_default(Mix.env(), key))
end
def validate_print_conf(nil), do: :ok
def validate_print_conf(:all), do: :ok
def validate_print_conf(:overdue), do: :ok
def validate_print_conf(value) do
raise "Bad print configuration value #{inspect(value)}."
end
defmacro __using__(opts) do
custom_persist = warn_literal(Keyword.get(opts, :persist))
persist_conf = if custom_persist == nil, do: config(:persist), else: custom_persist
persist = false !== persist_conf
Module.register_attribute(__CALLER__.module, :todo,
accumulate: true,
persist: persist
)
quote do
mix_env = Mix.env()
if unquote(persist) and mix_env not in ~w(dev test)a do
TODO.warn_persist_in_prod(__MODULE__, mix_env)
end
import TODO, only: [todo: 1]
end
end
defp warn_literal(persist) when is_boolean(persist) when nil == persist, do: persist
defp warn_literal(other) do
"""
The :persist configuration given to `use TODO` must be a literal boolean, got:
#{Macro.to_string(other)}
This is required to support calls to the `todo` macro in the module body scope.
"""
|> IO.warn()
nil
end
@doc false
def warn_persist_in_prod(module, mix_env) do
case :persistent_term.get(:todo_prod_warning, false) do
true ->
:ok
_ ->
:persistent_term.put(:todo_prod_warning, true)
IO.warn("""
TODO attributes are persisted in module #{inspect(module)} whereas environment is neither :dev nor :test. (#{inspect(mix_env)})
You can disable persistence in your configuration, for instance in
config/prod.exs :
config :todo, persist: false
""")
end
end
defmacro todo(items) do
Module.put_attribute(__CALLER__.module, :todo, items)
[]
end
def get_todos(module) when is_atom(module) do
for {:todo, sublist} <- module.module_info(:attributes) do
sublist
end
|> :lists.flatten()
|> Enum.map(&put_meta(&1, module))
end
def get_todos(modules) when is_list(modules) do
modules
|> Enum.map(&get_todos/1)
|> :lists.flatten()
end
defp put_meta({vsn, msg}, module) do
vsn = if is_atom(vsn), do: Atom.to_string(vsn), else: vsn
case Version.parse(vsn) do
{:ok, vsn} -> {vsn, module, msg}
:error -> {:no_version, module, "(Invalid vsn) #{msg}"}
end
end
defp put_meta(msg, module), do: {:no_version, module, msg}
def output_todos(todos, print_spec, %Version{} = max_vsn)
when length(todos) > 0 and print_spec in [:all, :overdue] do
groups =
case print_spec do
:all -> todos
:overdue -> filter_overdue(todos, max_vsn)
end
# Group by version
|> Enum.group_by(&elem(&1, 0))
{unversionned, versionned} =
case Map.pop(groups, :no_version) do
{nil, versionned} -> {[], versionned}
tuple -> tuple
end
versionned = Enum.sort_by(versionned, &elem(&1, 0), Version) |> :lists.reverse()
{:ok, width} = :io.columns()
width = min(width, 70)
# Main title
[?\n, IO.ANSI.cyan(), String.pad_trailing("-- TODO ", width, "-"), IO.ANSI.reset(), ?\n, ?\n]
|> IO.puts()
if length(unversionned) > 0 do
output_vsn_group(unversionned, version_title("Unversionned"), :normal, width)
|> IO.puts()
end
versionned
|> Enum.map(fn {vsn, todos} ->
mode = colorspec(vsn, max_vsn)
title = version_title(vsn, mode)
output_vsn_group(todos, title, mode, width)
|> IO.puts()
end)
end
def output_todos(todos, print_spec, %Version{})
when length(todos) == 0 and print_spec in [:all, :overdue] do
case print_spec do
:all ->
IO.puts("No todos found.")
:overdue ->
IO.puts("No overdue todos. You're fine.")
end
end
defp colorspec(vsn, max_vsn) do
if overdue?(vsn, max_vsn),
do: :warn,
else: :normal
end
defp output_vsn_group(todos, title, mode, width) do
todos_by_mods =
todos
|> group_by_module()
|> Enum.map(fn {module, todos} ->
todolist = Enum.map(todos, &["– ", todo_to_string(&1, width, 2), "\n"])
{todolist, module_color} =
case mode do
:normal -> {todolist, :cyan}
:warn -> {color(todolist, :yellow), :light_red}
end
source_link = format_module_link(module)
[
color([inspect(module)], module_color),
source_link,
"\n",
todolist
]
end)
|> Enum.intersperse("\n")
[title, "\n\n", todos_by_mods, "\n"]
end
defp wrap_block(str, width, indent) do
line_prefix = String.duplicate(" ", indent)
str
# Splitting block on two consecutive line breaks to preserve paragraphs but
# not simple breaks
|> String.split("\n\n")
|> Enum.map(&wrap_line(&1, width - indent, line_prefix))
|> Enum.intersperse("\n\n")
end
defp wrap_line(str, width, line_prefix) do
words =
str
|> String.replace("\n", " ")
|> String.split(" ", trim: true)
Enum.reduce(words, {[], 0}, fn word, {line, len} ->
wlen = String.length(word)
if wlen + len > width do
{[word, ["\n", line_prefix] | line], wlen}
else
case len do
0 -> {[word | line], len + wlen + 1}
_ -> {[word, " " | line], len + wlen + 1}
end
end
end)
|> elem(0)
|> :lists.reverse()
end
defp group_by_module(todos) do
todos
|> Enum.group_by(&elem(&1, 1))
|> Enum.sort_by(&elem(&1, 0))
end
# Return only todos whose version is lower or equal to max_vsn
defp filter_overdue(todos, max_vsn) do
Enum.filter(todos, fn
{:no_version, _, _} -> true
{vsn, _, _} -> overdue?(vsn, max_vsn)
end)
end
defp color(msg, col) do
[apply(IO.ANSI, col, []), msg, IO.ANSI.default_color()]
end
defp todo_to_string({_, _, msg}, width, indent) do
if is_binary(msg) do
msg
else
inspect(msg)
end
|> String.trim()
|> wrap_block(width, indent)
end
defp overdue?(vsn, max_vsn) do
Version.compare(vsn, max_vsn) != :gt
end
defp version_title(%Version{} = vsn, :normal),
do: version_title("Version #{vsn}")
defp version_title(%Version{} = vsn, :warn),
do: [version_title("Version #{vsn} – "), color("OVERDUE", :light_red)]
defp version_title(title),
do: "# #{title}"
defp format_module_link(module) do
# for {f, 0} <- IO.ANSI.module_info(:exports), f not in [:reset, :clear] do
# IO.puts([
# String.pad_trailing(to_string(f), 50),
# ": ",
# apply(IO.ANSI, f, []),
# "sample text",
# IO.ANSI.reset()
# ])
# end
case get_module_source(module) do
nil -> []
path -> [color([" ", path], :light_black)]
end
end
defp get_module_source(module) do
compile_info = module.module_info(:compile)
case Keyword.fetch(compile_info, :source) do
:error ->
nil
{:ok, source} ->
source
|> to_string()
|> Path.relative_to(File.cwd!())
end
end
end