defmodule Egglog.Common do
@moduledoc false
@snapshot_artifact_keys [:render, :path, :dot_path, :svg_path, :json_path]
def from_native_load({:ok, ref}), do: {:ok, ref}
def from_native_load({:error, reason, message}), do: {:error, {reason, message}}
def from_native_result({:error, reason, message}, _limit), do: {:error, {reason, message}}
def from_native_result(
{:ok, status, outputs, numeric_stats, text_stats, snapshot, report},
limit
) do
{outputs, limited?} = limit_outputs(outputs, limit)
{snapshot_format, snapshot_text, snapshot_omitted, snapshot_stats} = snapshot
result = %{
status: if(limited?, do: :limit, else: status),
outputs:
Enum.map(outputs, fn {type, text} -> %{type: String.to_atom(type), text: text} end),
stats: stats(numeric_stats ++ snapshot_stats, text_stats),
report: report(report)
}
{:ok, put_snapshot(result, snapshot_format, snapshot_text, snapshot_omitted)}
end
def from_native_close(:ok), do: :ok
def from_native_close({:error, reason, message}), do: {:error, {reason, message}}
def from_native_value({:ok, sort, kind, value}), do: {:ok, value(sort, kind, value)}
def from_native_value({:error, reason, message}), do: {:error, {reason, message}}
def from_native_lookup({:ok, false, _sort, _kind, _value}), do: {:ok, nil}
def from_native_lookup({:ok, true, sort, kind, value}), do: {:ok, value(sort, kind, value)}
def from_native_lookup({:error, reason, message}), do: {:error, {reason, message}}
def from_native_count({:ok, count}), do: {:ok, count}
def from_native_count({:error, reason, message}), do: {:error, {reason, message}}
def snapshot_from_result(%{snapshot: native_snapshot, stats: stats} = result, opts, render) do
with {:ok, rendered} <- render_snapshot(native_snapshot.text, native_snapshot.format, render) do
snapshot =
%{
format: rendered.format,
text: rendered.text,
dot: Map.get(rendered, :dot),
svg: Map.get(rendered, :svg),
json: Map.get(rendered, :json),
omitted: native_snapshot.omitted,
stats: Map.take(stats, [:snapshot_nodes, :snapshot_classes]),
result: result
}
|> write_snapshot_artifacts(opts)
{:ok, snapshot}
end
end
def snapshot_from_result(_result, _opts, _render), do: {:error, :missing_snapshot}
def snapshot_options(opts, input \\ %{}) do
%{
format: get(opts, :snapshot, get(input, :snapshot, :none)) |> snapshot_format(),
max_functions:
get(opts, :snapshot_max_functions, get(input, :snapshot_max_functions, 0))
|> nonnegative_integer(),
max_calls_per_function:
get(
opts,
:snapshot_max_calls_per_function,
get(input, :snapshot_max_calls_per_function, 0)
)
|> nonnegative_integer(),
inline_leaves:
get(opts, :snapshot_inline_leaves, get(input, :snapshot_inline_leaves, 0))
|> nonnegative_integer(),
split_primitive_outputs?:
get(
opts,
:snapshot_split_primitive_outputs,
get(input, :snapshot_split_primitive_outputs, false)
)
|> truthy?()
}
end
def native_snapshot_format(render) when render in [:json, "json"], do: :json
def native_snapshot_format(_render), do: :dot
def snapshot_run_opts(opts) do
render = get(opts, :render, :auto)
run_opts =
opts
|> Map.drop(@snapshot_artifact_keys)
|> Map.put(:snapshot, native_snapshot_format(render))
{render, run_opts}
end
def check_result({:ok, _result}), do: {:ok, true}
def check_result({:error, {:native_error, message}})
when is_binary(message) do
if String.contains?(message, "Check failed"),
do: {:ok, false},
else: {:error, {:native_error, message}}
end
def check_result({:error, reason}), do: {:error, reason}
def ok_only({:ok, _result}), do: :ok
def ok_only({:error, reason}), do: {:error, reason}
def extract_request(expr, opts) do
case get(option_map(opts), :variants) do
nil -> "(extract #{expr})"
variants -> "(extract #{expr} #{variants})"
end
end
def extraction({:ok, result}) do
text =
result.outputs
|> Enum.filter(&(&1.type in [:extract, :extract_variants]))
|> Enum.map_join(& &1.text)
|> String.trim_trailing("\n")
{:ok, text}
end
def extraction({:error, reason}), do: {:error, reason}
def bang({:ok, value}, _message), do: value
def bang({:error, reason}, message), do: raise("#{message}: #{inspect(reason)}")
def join_commands(commands) when is_list(commands) do
commands
|> List.flatten()
|> Enum.reject(&blank?/1)
|> Enum.join("\n")
end
def option_map(opts) when is_map(opts), do: opts
def option_map(opts) when is_list(opts), do: Map.new(opts)
def get(map, key, default \\ nil) when is_map(map) do
Map.get(map, key, Map.get(map, to_string(key), default))
end
def required!(map, key),
do: get(map, key) || raise(ArgumentError, "missing required key #{inspect(key)}")
def positive_integer(value) when is_integer(value) and value > 0, do: value
def positive_integer(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int > 0 -> int
_ -> nil
end
end
def positive_integer(_value), do: nil
def nonnegative_integer(value) when is_integer(value) and value >= 0, do: value
def nonnegative_integer(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int >= 0 -> int
_ -> 0
end
end
def nonnegative_integer(_value), do: 0
def blank?(nil), do: true
def blank?(value) when is_binary(value), do: String.trim(value) == ""
def blank?(_value), do: false
def truthy?(value), do: value in [true, "true", "1", 1]
defp stats(numeric_stats, text_stats) do
numeric_stats
|> Map.new(fn {key, value} -> {String.to_atom(key), value} end)
|> Map.merge(Map.new(text_stats, fn {key, value} -> {String.to_atom(key), value} end))
end
defp report(report_groups) do
Map.new(report_groups, fn {group, entries} ->
{String.to_atom(group), Map.new(entries)}
end)
end
defp value(sort, kind, raw) do
%{
sort: sort,
type: String.to_atom(kind),
value: decode_value(kind, raw)
}
end
defp decode_value("integer", raw), do: String.to_integer(raw)
defp decode_value("float", raw) do
case Float.parse(raw) do
{float, ""} -> float
_other -> raw
end
end
defp decode_value("boolean", "true"), do: true
defp decode_value("boolean", "false"), do: false
defp decode_value("unit", _raw), do: nil
defp decode_value(_kind, raw), do: raw
defp limit_outputs(outputs, nil), do: {outputs, false}
defp limit_outputs(outputs, limit) do
case positive_integer(limit) do
nil -> {outputs, false}
limit -> {Enum.take(outputs, limit), length(outputs) > limit}
end
end
defp put_snapshot(result, "", _text, _omitted), do: result
defp put_snapshot(result, "none", _text, _omitted), do: result
defp put_snapshot(result, format, text, omitted) do
Map.put(result, :snapshot, %{format: String.to_atom(format), text: text, omitted: omitted})
end
defp render_snapshot(json, :json, render) when render in [:json, "json"] do
{:ok, %{format: :json, text: json, json: json}}
end
defp render_snapshot(text, format, _render) when format != :dot do
{:ok, %{format: format, text: text}}
end
defp render_snapshot(dot, :dot, render) when render in [:dot, "dot", false, nil] do
{:ok, %{format: :dot, text: dot, dot: dot}}
end
defp render_snapshot(dot, :dot, render) when render in [:auto, "auto", :svg, "svg", true] do
case System.find_executable("dot") do
nil when render in [:auto, "auto", true] ->
{:ok, %{format: :dot, text: dot, dot: dot}}
nil ->
{:error, {:graphviz_missing, "could not find dot executable on PATH"}}
_dot ->
render_svg(dot)
end
end
defp render_snapshot(_text, _format, render), do: {:error, {:unsupported_render, render}}
defp render_svg(dot) do
dot_path = Path.join(System.tmp_dir!(), "egglog-#{System.unique_integer([:positive])}.dot")
svg_path = Path.rootname(dot_path) <> ".svg"
try do
File.write!(dot_path, dot)
case System.cmd("dot", ["-Tsvg", dot_path, "-o", svg_path], stderr_to_stdout: true) do
{_output, 0} ->
svg = File.read!(svg_path)
{:ok, %{format: :svg, text: svg, svg: svg, dot: dot}}
{output, status} ->
{:error, {:graphviz_failed, status, output}}
end
after
File.rm(dot_path)
File.rm(svg_path)
end
end
defp write_snapshot_artifacts(snapshot, opts) do
snapshot
|> maybe_write(:dot_path, snapshot.dot, opts)
|> maybe_write(:svg_path, Map.get(snapshot, :svg), opts)
|> maybe_write(:json_path, Map.get(snapshot, :json), opts)
|> maybe_write(:path, snapshot.text, opts)
end
defp maybe_write(snapshot, key, content, opts) do
case get(opts, key) do
nil -> snapshot
_path when is_nil(content) -> Map.put(snapshot, key, nil)
path -> write_file(snapshot, key, path, content)
end
end
defp write_file(snapshot, key, path, content) do
path = Path.expand(path)
File.mkdir_p!(Path.dirname(path))
File.write!(path, content)
Map.put(snapshot, key, path)
end
defp snapshot_format(nil), do: "none"
defp snapshot_format(false), do: "none"
defp snapshot_format(true), do: "dot"
defp snapshot_format(format) when format in [:dot, "dot"], do: "dot"
defp snapshot_format(format) when format in [:json, "json"], do: "json"
defp snapshot_format(format) when format in [:none, "none"], do: "none"
defp snapshot_format(other), do: raise(ArgumentError, "unsupported snapshot: #{inspect(other)}")
end