Current section

Files

Jump to
posthog lib mix tasks posthog.public_api.ex
Raw

lib/mix/tasks/posthog.public_api.ex

defmodule Mix.Tasks.Posthog.PublicApi do
@moduledoc """
Checks the committed public API snapshot.
The snapshot is generated from compiled BEAM metadata. Public modules are
discovered from the current Mix application. `PostHog.*` modules and
documented `Mix.Tasks.Posthog.*` modules are included. `Code.fetch_docs/1` is
used to identify documented modules and function visibility, and `__info__/1`
is used to enumerate exported functions and macros. Mix tasks also include
their task name, shortdoc, and module documentation text so command/options
documentation changes are checked.
mix posthog.public_api --check
mix posthog.public_api --update
## Options
* `--check` - Compare the generated snapshot with the committed snapshot.
This is the default.
* `--update` - Regenerate the committed snapshot.
* `--snapshot` - Snapshot path. Defaults to `public_api.snapshot`.
"""
use Mix.Task
@shortdoc "Checks the committed public API snapshot"
@default_snapshot "public_api.snapshot"
@impl true
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [check: :boolean, update: :boolean, snapshot: :string]
)
check? = Keyword.get(opts, :check, false)
update? = Keyword.get(opts, :update, false)
snapshot_path = Keyword.get(opts, :snapshot, @default_snapshot)
if check? and update? do
Mix.raise("Pass only one of --check or --update")
end
Mix.Task.run("compile")
snapshot = generate_snapshot()
if update? do
write_snapshot(snapshot_path, snapshot)
else
check_snapshot(snapshot_path, snapshot)
end
end
defp generate_snapshot do
modules = public_modules()
[
"# This file is generated by `mix posthog.public_api --update`.",
"# Do not edit manually.",
"",
"# Public modules/functions/macros and documented Mix tasks for #{Mix.Project.config()[:app]}",
""
| Enum.flat_map(modules, &module_snapshot/1)
]
|> Enum.join("\n")
|> then(&(&1 <> "\n"))
end
defp public_modules do
app = Mix.Project.config()[:app]
Application.load(app)
case :application.get_key(app, :modules) do
{:ok, modules} ->
modules
|> Enum.filter(&public_module?/1)
|> Enum.reject(&hidden_module?/1)
|> Enum.sort_by(&inspect/1)
:undefined ->
Mix.raise("Could not load compiled modules for #{inspect(app)}")
end
end
defp public_module?(module) do
posthog_module?(module) or documented_posthog_mix_task?(module)
end
defp posthog_module?(module) do
module
|> Atom.to_string()
|> String.starts_with?("Elixir.PostHog")
end
defp documented_posthog_mix_task?(module) do
posthog_mix_task?(module) and documented_doc?(fetch_docs(module) |> elem(0))
end
defp posthog_mix_task?(module) do
module
|> Atom.to_string()
|> String.starts_with?("Elixir.Mix.Tasks.Posthog.")
end
defp hidden_module?(module) do
module
|> fetch_docs()
|> elem(0)
|> Kernel.==(:hidden)
end
defp module_snapshot(module) do
{module_doc, docs} = fetch_docs(module)
docs_by_id = docs_by_id(docs)
[
inspect(module),
" module_doc: #{doc_visibility(module_doc)}"
] ++
mix_task_snapshot(module, module_doc) ++
exported_snapshot(module, :functions, :function, docs_by_id) ++
exported_snapshot(module, :macros, :macro, docs_by_id) ++ [""]
end
defp mix_task_snapshot(module, module_doc) do
if posthog_mix_task?(module) do
[
" mix_task: #{Mix.Task.task_name(module)}",
" shortdoc: #{inspect(Mix.Task.shortdoc(module))}",
" module_doc_text:"
| module_doc_text_lines(module_doc)
]
else
[]
end
end
defp exported_snapshot(module, info_key, kind, docs_by_id) do
exports = module.__info__(info_key)
lines =
exports
|> Enum.sort()
|> Enum.reject(fn {name, arity} ->
Map.get(docs_by_id, {kind, name, arity}, :none) == :hidden
end)
|> Enum.map(fn {name, arity} ->
doc = Map.get(docs_by_id, {kind, name, arity}, :none)
" #{name}/#{arity} doc: #{doc_visibility(doc)}"
end)
[" #{info_key}:" | empty_or_lines(lines)]
end
defp empty_or_lines([]), do: [" (none)"]
defp empty_or_lines(lines), do: lines
defp docs_by_id(docs) do
Map.new(docs, fn {{kind, name, arity}, _line, _signature, doc, _metadata} ->
{{kind, name, arity}, doc}
end)
end
defp fetch_docs(module) do
case Code.fetch_docs(module) do
{:docs_v1, _anno, _beam_language, _format, module_doc, _metadata, docs} ->
{module_doc, docs}
{:error, reason} ->
Mix.raise("Could not fetch docs for #{inspect(module)}: #{inspect(reason)}")
end
end
defp documented_doc?(:hidden), do: false
defp documented_doc?(:none), do: false
defp documented_doc?(_doc), do: true
defp doc_visibility(:hidden), do: "hidden"
defp doc_visibility(:none), do: "none"
defp doc_visibility(_doc), do: "documented"
defp module_doc_text_lines(%{"en" => text}) do
text
|> String.trim_trailing()
|> String.split("\n")
|> Enum.map(fn
"" -> " |"
line -> " | #{line}"
end)
end
defp module_doc_text_lines(_module_doc), do: [" (unavailable)"]
defp write_snapshot(path, snapshot) do
path
|> Path.dirname()
|> File.mkdir_p!()
File.write!(path, snapshot)
Mix.shell().info("Updated #{path}")
end
defp check_snapshot(path, snapshot) do
case File.read(path) do
{:ok, ^snapshot} ->
Mix.shell().info("Public API snapshot is up to date")
{:ok, current} ->
Mix.raise("""
Public API snapshot is out of date.
Run `mix posthog.public_api --update` and commit #{path}.
To inspect the change locally, run `mix posthog.public_api --update` and then `git diff -- #{path}`.
Diff:
#{snapshot_diff(path, current, snapshot)}
""")
{:error, :enoent} ->
Mix.raise("""
Public API snapshot does not exist.
Run `mix posthog.public_api --update` and commit #{path}.
""")
{:error, reason} ->
Mix.raise("Could not read #{path}: #{inspect(reason)}")
end
end
defp snapshot_diff(path, current, snapshot) do
generated_path =
System.tmp_dir!()
|> Path.join("#{Path.basename(path)}.generated-#{System.unique_integer([:positive])}")
try do
File.write!(generated_path, snapshot)
{diff, _status} =
System.cmd("git", ["diff", "--no-index", "--", path, generated_path],
stderr_to_stdout: true
)
diff
rescue
_error ->
"Could not generate diff. Current snapshot bytes: #{byte_size(current)}. Generated snapshot bytes: #{byte_size(snapshot)}."
after
File.rm(generated_path)
end
end
end