defmodule Serum.Plugin.Loader do @moduledoc false _moduledocp = "A module for loading Serum plugins from serum.exs." import Serum.IOProxy alias Serum.Plugin alias Serum.Plugin.EnvMatcher alias Serum.Result @serum_version Version.parse!(Mix.Project.config()[:version]) @elixir_version Version.parse!(System.version()) @old_callback_arities %{ build_started: 2, reading_pages: 1, reading_posts: 1, reading_templates: 1, processing_page: 1, processing_post: 1, processing_template: 1, processed_page: 1, processed_post: 1, processed_template: 1, processed_list: 1, processed_pages: 1, processed_posts: 1, rendering_fragment: 2, rendered_fragment: 1, rendered_page: 1, wrote_file: 1, build_succeeded: 2, build_failed: 3, finalizing: 2 } @spec load_plugins([term()]) :: Result.t([Plugin.t()]) def load_plugins(modules) do modules |> Enum.map(&validate_spec/1) |> Result.aggregate_values(:load_plugins) |> case do {:ok, specs} -> do_load_plugins(specs) {:error, _} = error -> error end end @spec do_load_plugins([Plugin.spec()]) :: Result.t([Plugin.t()]) defp do_load_plugins(specs) do specs |> Enum.filter(&EnvMatcher.env_matches?/1) |> Enum.uniq_by(fn module when is_atom(module) -> module {module, _} when is_atom(module) -> module end) |> Enum.map(&make_plugin/1) |> Result.aggregate_values(:load_plugins) |> case do {:ok, plugins} -> update_agent(plugins) {:ok, plugins} {:error, _} = error -> error end end @spec validate_spec(term()) :: Result.t(Plugin.spec()) defp validate_spec(maybe_spec) defp validate_spec(module) when is_atom(module), do: {:ok, module} defp validate_spec({module, opts}) when is_atom(module) and is_list(opts) do if Keyword.keyword?(opts) do {:ok, {module, opts}} else {:error, "expected the second tuple element to be a keyword list, " <> "got: #{inspect(opts)}"} end end defp validate_spec(x) do {:error, "#{inspect(x)} is not a valid Serum plugin specification"} end @spec make_plugin(Plugin.spec()) :: Result.t(Plugin.t()) defp make_plugin(plugin_spec) defp make_plugin(mod) when is_atom(mod), do: do_make_plugin(mod, nil) defp make_plugin({mod, opts}) when is_atom(mod) and is_list(opts) do do_make_plugin(mod, opts[:args]) end @spec do_make_plugin(atom(), term()) :: Result.t(Plugin.t()) defp do_make_plugin(module, args) do name = module.name() version = Version.parse!(module.version()) elixir = module.elixir() serum = module.serum() validate_elixir_version(name, elixir) validate_serum_version(name, serum) plugin = %Plugin{ module: module, name: name, version: version, description: module.description(), implements: normalized_implements(module), args: args } {:ok, plugin} rescue exception -> ex_name = module_name(exception.__struct__) ex_msg = Exception.message(exception) msg = "#{ex_name} while loading plugin (module: #{module}): #{ex_msg}" {:error, msg} end @spec validate_elixir_version(binary(), Version.requirement()) :: :ok defp validate_elixir_version(name, requirement) do if Version.match?(@elixir_version, requirement) do :ok else msg = "The plugin \"#{name}\" is not compatible with " <> "the current version of Elixir(#{@elixir_version}). " <> "This plugin may not work as intended." put_err(:warn, msg) end end @spec validate_serum_version(binary(), Version.requirement()) :: :ok defp validate_serum_version(name, requirement) do if Version.match?(@serum_version, requirement) do :ok else msg = "The plugin \"#{name}\" is not compatible with " <> "the current version of Serum(#{@serum_version}). " <> "This plugin may not work as intended." put_err(:warn, msg) end end @spec normalized_implements(module()) :: [{atom(), integer()}] defp normalized_implements(module) do Enum.map(module.implements(), fn fun when is_atom(fun) -> {fun, @old_callback_arities[fun] || 0} {fun, arity} when is_atom(fun) and is_integer(arity) -> {fun, arity} end) end @spec update_agent([Plugin.t()]) :: :ok defp update_agent(plugins) do map = plugins |> Enum.map(fn plugin -> Enum.map(plugin.implements, &Tuple.insert_at(&1, 2, plugin)) end) |> List.flatten() |> Enum.group_by(&elem(&1, 0), &Tuple.delete_at(&1, 0)) |> Map.new() Agent.update(Plugin, fn _ -> map end) end @spec module_name(atom()) :: binary() defp module_name(module) do module |> to_string() |> String.replace_prefix("Elixir.", "") end end