Current section
Files
Jump to
Current section
Files
lib/pi/output.ex
defmodule Pi.Output do
@moduledoc "Eval-friendly structured output helpers for pi renderers."
alias Pi.Protocol.Tool.OutputPart
defstruct parts: [], text: nil
@type t :: %__MODULE__{parts: [OutputPart.t()], text: String.t() | nil}
@doc "Wraps rows as a structured table output."
def table(rows, opts \\ []) when is_list(rows) do
{columns, row_values} = table_data(rows, opts)
preview = Keyword.get(opts, :preview) || table_preview(length(row_values), length(columns))
%__MODULE__{
parts: [
%OutputPart{
format: :table,
output: Jason.encode!(%{columns: columns, rows: row_values}),
preview: preview
}
],
text: inspect(rows, inspect_opts())
}
end
@doc "Wraps any value as a tree output."
def tree(value, opts \\ []) do
preview = Keyword.get(opts, :preview) || tree_preview(value)
%__MODULE__{
parts: [
%OutputPart{
format: :tree,
output: Jason.encode!(tree_value(value, 0, Keyword.get(opts, :depth, 4))),
preview: preview
}
],
text: inspect(value, inspect_opts())
}
end
@doc "Wraps source code for syntax-highlighted rendering."
def code(source, language \\ :elixir, opts \\ []) when is_binary(source) do
%__MODULE__{
parts: [
%OutputPart{
format: :source,
output: source,
language: to_string(language),
preview: Keyword.get(opts, :preview) || first_line(source)
}
],
text: source
}
end
@doc "Wraps plain text output."
def text(text, opts \\ []) when is_binary(text) do
%__MODULE__{
parts: [%OutputPart{format: :text, output: text, preview: Keyword.get(opts, :preview)}],
text: text
}
end
@doc false
def auto(value) do
cond do
table_like?(value) -> table(value)
map_size_safe(value) -> tree(value)
true -> nil
end
end
@doc false
def parts_for(%__MODULE__{parts: parts}) when is_list(parts), do: parts
def parts_for(value) do
case auto(value) do
%__MODULE__{parts: parts} -> parts
nil -> nil
end
end
@doc false
def text_for(%__MODULE__{text: text}) when is_binary(text), do: text
def text_for(_value), do: nil
defp table_data(rows, opts) do
columns = Keyword.get(opts, :columns) || infer_columns(rows)
column_strings = Enum.map(columns, &to_string/1)
row_values =
Enum.map(rows, fn row ->
Enum.map(columns, fn column -> cell(row, column) end)
end)
{column_strings, row_values}
end
defp infer_columns(rows) do
rows
|> Enum.flat_map(fn
row when is_map(row) -> Map.keys(row)
row when is_list(row) -> Keyword.keys(row)
_other -> []
end)
|> Enum.uniq()
|> Enum.sort_by(&to_string/1)
end
defp cell(row, column) when is_map(row) do
row
|> Map.get(column, Map.get(row, to_string(column)))
|> cell_text()
end
defp cell(row, column) when is_list(row), do: row |> Keyword.get(column) |> cell_text()
defp cell(_row, _column), do: ""
defp cell_text(nil), do: ""
defp cell_text(value) when is_binary(value), do: value
defp cell_text(value) when is_atom(value) or is_number(value) or is_boolean(value),
do: inspect(value)
defp cell_text(value), do: inspect(value, inspect_opts())
defp table_like?([first | _]) when is_map(first), do: true
defp table_like?([first | _]) when is_list(first), do: Keyword.keyword?(first)
defp table_like?(_other), do: false
defp map_size_safe(value) when is_map(value), do: map_size(value) > 0
defp map_size_safe(_value), do: false
defp tree_value(value, depth, max_depth) when depth >= max_depth do
inspect(value, inspect_opts())
end
defp tree_value(value, depth, max_depth) when is_map(value) do
value
|> Enum.map(fn {key, child} ->
%{key: inspect(key), value: tree_value(child, depth + 1, max_depth)}
end)
end
defp tree_value(value, depth, max_depth) when is_list(value) do
value
|> Enum.with_index()
|> Enum.map(fn {child, index} ->
%{key: Integer.to_string(index), value: tree_value(child, depth + 1, max_depth)}
end)
end
defp tree_value(value, _depth, _max_depth), do: cell_text(value)
defp table_preview(rows, columns), do: "#{rows} rows × #{columns} columns"
defp tree_preview(value) when is_map(value), do: "map with #{map_size(value)} keys"
defp tree_preview(value) when is_list(value), do: "list with #{length(value)} items"
defp tree_preview(_value), do: "tree"
defp first_line(source) do
source
|> String.split("\n", parts: 2)
|> List.first()
|> Kernel.||("")
end
defp inspect_opts, do: [charlists: :as_lists, limit: 50, pretty: true]
end