defmodule Mix.Tasks.Compile.Finitomata do # credo:disable-for-this-file Credo.Check.Readability.Specs @moduledoc false use Mix.Task.Compiler alias Mix.Task.Compiler alias Finitomata.{Hook, Mix.Events, Transition} @preferred_cli_env :dev @manifest_events "finitomata" @impl Compiler def run(argv) do case Events.start_link() do {:ok, _pid} -> :ok {:error, {:already_started, _pid}} -> # IO.warn("`Events` process has been attempted to start twice") :ok {:error, error} -> raise CompileError, description: "Cannot start the `Events` process, error: " <> inspect(error) end Compiler.after_compiler(:app, &after_compiler(&1, argv)) tracers = Code.get_compiler_option(:tracers) Code.put_compiler_option(:tracers, [__MODULE__ | tracers]) {:ok, []} end @doc false @impl Compiler def manifests, do: [manifest_path(@manifest_events)] @doc false @impl Compiler def clean, do: if(GenServer.whereis(Events), do: Events.stop()) @doc false def trace({remote, meta, Finitomata, :__using__, 1}, env) when remote in ~w|remote_macro imported_macro|a do Events.put( :declarations, struct(Finitomata.Hook, env: env, module: env.module, kind: :defmacro, fun: :__using__, arity: 1, args: meta ) ) :ok end def trace({:remote_macro, _meta, Finitomata.Hook, :__before_compile__, 1}, %Macro.Env{ module: module }) do module |> Events.hooks() |> to_diagnostics(module |> Module.get_attribute(:__config__) |> Map.get(:fsm)) |> add_diagnostics() |> amend_using_info(module) end def trace(_event, _env), do: :ok @spec after_compiler({status, [Mix.Task.Compiler.Diagnostic.t()]}, any()) :: {status, [Mix.Task.Compiler.Diagnostic.t()]} when status: Mix.Task.Compiler.status() defp after_compiler({status, diagnostics}, _argv) do tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == __MODULE__)) Code.put_compiler_option(:tracers, tracers) %{diagnostics: finitomata_diagnostics} = Events.all() :ok = Events.stop() {finitomata_status, _full, _added, _removed} = manifest(finitomata_diagnostics) status = case {status, finitomata_status} do {:error, _} -> :error {:noop, status} -> status {:ok, _} -> :ok end {status, diagnostics ++ MapSet.to_list(finitomata_diagnostics)} end @spec diagnostic(message :: binary(), opts :: keyword()) :: Mix.Task.Compiler.Diagnostic.t() defp diagnostic(message, opts) do %Mix.Task.Compiler.Diagnostic{ compiler_name: "finitomata", details: nil, file: "unknown", message: message, position: nil, severity: :information } |> Map.merge(Map.new(opts)) end @type ambiguous :: {Transition.state(), {Transition.event(), [Transition.state()]}} @type disambiguated :: {Transition.state(), {Transition.event(), [Transition.state()], Hook.t()}} @type diagnostics :: %{ explicit: [disambiguated()], partial: [disambiguated()], implicit: [disambiguated()], unhandled: [ambiguous()] } @spec to_diagnostics(Events.hooks(), [Transition.t()]) :: diagnostics() defp to_diagnostics(hooks, fsm) do declared = MapSet.to_list(hooks) initial = %{explicit: [], partial: [], implicit: [], unhandled: []} fsm |> Transition.ambiguous() |> Enum.reduce(initial, fn {from, {event, tos}}, acc -> add_diagnostic(declared, {from, {event, tos}}, acc) end) end defmacrop v, do: {:{}, [], [{:_, [], nil}, {:_, [], nil}, nil]} @spec add_diagnostic([Hook.t()], ambiguous(), diagnostics()) :: diagnostics() defp add_diagnostic(hooks, {from, {event, tos}}, diagnostics) do hooks |> Enum.reduce_while(diagnostics, fn %Hook{args: [^from, ^event, v(), v()]} = hook, acc -> {:halt, %{acc | explicit: [{from, {event, tos, hook}} | acc.explicit]}} %Hook{args: [^from, {e, _, _}, v(), v()], guards: []} = hook, acc when is_atom(e) -> {:halt, %{acc | partial: [{from, {event, tos, hook}} | acc.partial]}} %Hook{args: [{f, _, _}, ^event, v(), v()], guards: []} = hook, acc when is_atom(f) -> {:halt, %{acc | partial: [{from, {event, tos, hook}} | acc.partial]}} %Hook{args: [{f, _, _}, {e, _, _}, v(), v()], guards: []} = hook, acc when is_atom(f) and is_atom(e) -> {:halt, %{acc | implicit: [{from, {event, tos, hook}} | acc.implicit]}} %Hook{args: args, guards: guards} = hook, acc -> {:cont, cover(args, {from, {event, tos, hook}}, guards, acc)} %Hook{}, acc -> {:cont, acc} end) |> case do ^diagnostics -> %{diagnostics | unhandled: [{from, {event, tos}} | diagnostics.unhandled]} diagnostics -> diagnostics end |> then(fn diagnostics -> handled = diagnostics |> Map.take(~w|explicit partial implicit|a) |> Map.values() |> Enum.reduce( [], &(&2 ++ Enum.map(&1, fn {from, {event, tos, _hook}} -> {from, {event, tos}} end)) ) |> Enum.uniq() unhandled = diagnostics.unhandled |> Enum.uniq() |> Kernel.--(handled) %{diagnostics | unhandled: unhandled} end) end @spec add_diagnostics(diagnostics()) :: diagnostics() defp add_diagnostics(%{explicit: _, partial: _, implicit: _} = diagnostics) do diagnostics |> Map.take(~w|explicit partial implicit|a) |> Enum.each(fn {type, hooks} -> Enum.each(hooks, fn {_from, {_event, _tos, %Hook{} = hook}} = info -> Events.put( :diagnostics, type |> disambiguated_message(info) |> diagnostic( severity: disambiguated_security(type), details: Hook.details(hook), position: hook.env.line, file: hook.env.file ) ) end) end) diagnostics end @spec cover( {Transition.state(), Transition.event()} | [Hook.ast_tuple()], {Transition.state(), {Transition.event(), [Transition.state()], Hook.t()}}, [Hook.ast_tuple()], diagnostics() ) :: diagnostics() defp cover({f, e}, {from, {event, tos, _} = event_tos_hook}, guards, acc) when is_atom(f) and is_atom(e) do case covered?({f, e}, {from, event}, guards) do 3 -> %{acc | explicit: [{from, event_tos_hook} | acc.explicit]} 2 -> %{acc | partial: [{from, event_tos_hook} | acc.partial]} 1 -> %{acc | implicit: [{from, event_tos_hook} | acc.implicit]} 0 -> %{acc | unhandled: [{from, {event, tos}} | acc.unhandled]} end end defp cover([f, {e, _, _}, _, _], {from, event}, guards, acc) when is_atom(f), do: cover({f, e}, {from, event}, guards, acc) defp cover([{f, _, _}, e, _, _], {from, event}, guards, acc) when is_atom(e), do: cover({f, e}, {from, event}, guards, acc) defp cover([{f, _, _}, {e, _, _}, _, _], {from, event}, guards, acc), do: cover({f, e}, {from, event}, guards, acc) # TODO [AM] more cases with pattern matching etc defp cover(_, _, _guards, acc), do: acc @spec covered?( {Transition.state(), Transition.event()}, {Transition.state(), Transition.event()}, [Hook.ast_tuple()] ) :: 0 | 1 | 2 | 3 defp covered?({f, e}, {from, event}, guards) do guards |> Macro.prewalk(0, fn {:in, _, [{^f, _, _}, list]} = t, acc -> {t, if(from in Macro.expand(list, __ENV__), do: Enum.max([acc, 2]), else: acc)} {:in, _, [{^e, _, _}, list]} = t, acc -> {t, if(event in Macro.expand(list, __ENV__), do: Enum.max([acc, 2]), else: acc)} {:==, _, [{^f, _, _}, ^from]} = t, _acc -> {t, 3} {:==, _, [^from, {^f, _, _}]} = t, _acc -> {t, 3} {:==, _, [{^e, _, _}, ^event]} = t, _acc -> {t, 3} {:==, _, [^event, {^e, _, _}]} = t, _acc -> {t, 3} t, acc -> {t, acc} end) |> elem(1) end @spec disambiguated_security(:explicit | :partial | :implicit) :: :hint | :info defp disambiguated_security(:explicit), do: :hint defp disambiguated_security(:partial), do: :hint defp disambiguated_security(:implicit), do: :information @spec disambiguated_message(:explicit | :partial | :implicit, disambiguated()) :: String.t() defp disambiguated_message(:explicit, {from, {event, tos, %Hook{} = _hook}}) do "Ambiguous transition " <> inspect(%Transition{from: from, to: tos, event: event}) <> " seems to be explicitly handled.\n" <> "Make sure all possible target states are reachable!" end defp disambiguated_message(:partial, {from, {event, tos, %Hook{} = _hook}}) do "Ambiguous transition " <> inspect(%Transition{from: from, to: tos, event: event}) <> " seems to be partially handled.\n" <> "Make sure all possible target states are reachable!" end defp disambiguated_message(:implicit, {from, {event, tos, %Hook{} = _hook}}) do "Ambiguous transition " <> inspect(%Transition{from: from, to: tos, event: event}) <> " seems to be implicitly handled.\n" <> "Make sure all possible target states are reachable!" end @spec amend_using_info(diagnostics(), module()) :: :ok defp amend_using_info(%{unhandled: []}, _module), do: :ok defp amend_using_info(%{unhandled: unhandled}, module) do module |> Events.declaration() |> case do %Hook{} = hook -> pos = if Keyword.keyword?(hook.args), do: Keyword.get(hook.args, :line, hook.env.line) message = [ "This FSM declaration contains ambiguous transitions which are not fully handled:" | Enum.map(unhandled, fn {from, {event, tos}} -> " " <> inspect(%Transition{from: from, to: tos, event: event}) <> " should be handled more strictly (have kinda catch-all clause)" end) ] |> Enum.join("\n") Events.put( :diagnostics, diagnostic(message, severity: :warning, details: Hook.details(hook), position: pos, file: hook.env.file ) ) nil -> Mix.shell().info([ [:bright, :yellow, "warning: ", :reset], "inconsistent FSM data collected from ", [:bright, :blue, inspect(module), :reset] ]) end end @doc experimental: true, todo: true @spec manifest(MapSet.t(Hook.t())) :: {Mix.Task.Compiler.status(), MapSet.t(Hook.t()), MapSet.t(Hook.t()), MapSet.t(Hook.t())} defp manifest(diagnostics) do {full, added, removed} = @manifest_events |> read_manifest() |> case do nil -> {diagnostics, diagnostics, []} old -> old_by_module = old |> Enum.filter(&File.exists?(&1.file)) |> Enum.group_by(& &1.file) diagnostics_by_module = Enum.group_by(diagnostics, & &1.file) full = old_by_module |> Map.merge(diagnostics_by_module) |> Map.values() |> List.flatten() |> MapSet.new() old = old_by_module |> Map.take(Map.keys(diagnostics_by_module)) |> Map.values() |> List.flatten() |> MapSet.new() {full, MapSet.difference(diagnostics, old), MapSet.difference(old, diagnostics)} end write_manifest(@manifest_events, full) status = [added, removed] |> Enum.map(fn diagnostics -> diagnostics |> Enum.filter(&match?(%Mix.Task.Compiler.Diagnostic{severity: :warning}, &1)) |> Enum.map(fn %Mix.Task.Compiler.Diagnostic{} = diagnostic -> loc = Enum.join([Path.relative_to_cwd(diagnostic.file), diagnostic.position], ":") {diagnostic.message, loc} end) end) |> case do [[], []] -> :noop [_added, _removed] -> :ok end full |> Enum.filter(&match?(%Mix.Task.Compiler.Diagnostic{severity: :warning}, &1)) |> Enum.each(fn %Mix.Task.Compiler.Diagnostic{} = diagnostic -> loc = Enum.join([Path.relative_to_cwd(diagnostic.file), diagnostic.position], ":") Mix.shell().info([ [:bright, :yellow, "warning: ", :reset], diagnostic.message, "\n ", loc ]) end) {status, full, added, removed} end @spec manifest_path(binary()) :: binary() defp manifest_path(name), do: Mix.Project.config() |> Mix.Project.manifest_path() |> Path.join("compile.#{name}") @spec read_manifest(binary()) :: term() defp read_manifest(name) do unless Mix.Utils.stale?([Mix.Project.config_mtime()], [manifest_path(name)]) do name |> manifest_path() |> File.read() |> case do {:ok, manifest} -> :erlang.binary_to_term(manifest) _ -> nil end end end @spec write_manifest(binary(), term()) :: :ok defp write_manifest(name, data) do path = manifest_path(name) File.mkdir_p!(Path.dirname(path)) File.write!(path, :erlang.term_to_binary(data)) end end