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, `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 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 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(&posthog_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 posthog_module?(module) do module |> Atom.to_string() |> String.starts_with?("Elixir.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)}" | exported_snapshot(module, :functions, :function, docs_by_id) ++ exported_snapshot(module, :macros, :macro, docs_by_id) ++ [""] ] 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 doc_visibility(:hidden), do: "hidden" defp doc_visibility(:none), do: "none" defp doc_visibility(_doc), do: "documented" 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