defmodule Foundry.PageMetadata do
@moduledoc """
Shared page metadata inference for LiveView modules.
This keeps route, subtype, feature flag, and action inference consistent
between `Foundry.Context.Introspector` and SparkMeta analyzers.
"""
@type route_info :: %{path: String.t(), dynamic: boolean()} | nil
@spec analyze(module(), route_info()) :: map()
def analyze(module, route_info \\ nil) do
ast = source_ast(module)
{page_route, page_dynamic} = resolve_route(module, ast, route_info)
%{
page_route: page_route,
page_dynamic: page_dynamic,
page_group: attribute_value(module, ast, :page_group),
page_subtype: page_subtype(module),
calls_actions: calls_actions(module, ast),
feature_flags: feature_flags(module, ast)
}
end
@spec calls_actions(module()) :: [map()]
def calls_actions(module) do
calls_actions(module, source_ast(module))
end
defp resolve_route(_module, _ast, %{path: path, dynamic: dynamic}) do
{path, dynamic}
end
defp resolve_route(module, ast, nil) do
case attribute_value(module, ast, :page_route) do
route when is_binary(route) ->
{route, String.contains?(route, ":")}
_ ->
case infer_route_from_source(ast) do
route when is_binary(route) -> {route, String.contains?(route, ":")}
_ -> {nil, false}
end
end
end
defp page_subtype(module) do
case module_attribute(module, :page_subtype) do
nil -> detect_sdui_subtype(module)
subtype -> subtype
end
end
defp detect_sdui_subtype(module) do
try do
if function_exported?(module, :__sdui_lookup__, 0) do
:sdui
else
nil
end
rescue
_ -> nil
end
end
defp feature_flags(module, ast) do
case attribute_value(module, ast, :feature_flags) do
nil -> []
flags when is_list(flags) -> flags
flag -> [flag]
end
end
defp calls_actions(module, ast) do
scanned = extract_ash_calls(ast)
annotated = annotated_calls_actions(module, ast)
merge_actions(scanned, annotated)
end
defp annotated_calls_actions(module, ast) do
case attribute_value(module, ast, :calls_actions) do
nil -> []
attrs when is_list(attrs) -> normalize_calls_actions(attrs)
_ -> normalize_calls_actions(List.wrap(module_attribute(module, :calls_actions)))
end
end
defp merge_actions(scanned, annotated) do
(scanned ++ annotated)
|> Enum.uniq_by(fn action ->
{Map.get(action, "resource"), Map.get(action, "action"), Map.get(action, "action_name")}
end)
end
defp normalize_calls_actions(attrs) do
Enum.flat_map(attrs, fn
{resource_module, action_type}
when is_atom(resource_module) and is_atom(action_type) and
action_type not in [true, false, nil] ->
action_name =
case action_type do
:create -> "create"
:read -> "read"
other -> Atom.to_string(other)
end
[
%{
"resource" => format_module(resource_module),
"action" => action_type,
"action_name" => action_name
}
]
map when is_map(map) ->
[map]
_ ->
[]
end)
end
defp infer_route_from_source(nil), do: nil
defp infer_route_from_source(ast) do
case sdui_lookup(ast) do
{:static, "home"} -> "/"
{:static, name} when is_binary(name) -> "/" <> name
_ -> nil
end
end
defp sdui_lookup(ast) do
ast
|> collect_sdui_lookup()
|> List.first()
|> decode_value()
end
defp collect_sdui_lookup({:defmodule, _, [{:__aliases__, _, _}, [do: body]]}) do
collect_sdui_lookup(body)
end
defp collect_sdui_lookup({:use, _, [{:__aliases__, _, [:AshSDUI]}, opts]}) when is_list(opts) do
opts
|> Keyword.get(:lookup)
|> List.wrap()
end
defp collect_sdui_lookup(list) when is_list(list) do
Enum.flat_map(list, &collect_sdui_lookup/1)
end
defp collect_sdui_lookup({_, _, args}) when is_list(args) do
Enum.flat_map(args, &collect_sdui_lookup/1)
end
defp collect_sdui_lookup(_), do: []
defp attribute_value(module, ast, attr_name) do
case attribute_from_source(ast, attr_name) do
nil -> module_attribute(module, attr_name)
value -> value
end
end
defp attribute_from_source(nil, _attr_name), do: nil
defp attribute_from_source(ast, attr_name) do
ast
|> collect_attribute(attr_name)
|> List.first()
|> decode_value()
end
defp collect_attribute({:defmodule, _, [{:__aliases__, _, _}, [do: body]]}, attr_name) do
collect_attribute(body, attr_name)
end
defp collect_attribute({:@, _, [{name, _, [value]}]}, attr_name) when name == attr_name do
[value]
end
defp collect_attribute(list, attr_name) when is_list(list) do
Enum.flat_map(list, &collect_attribute(&1, attr_name))
end
defp collect_attribute({_, _, args}, attr_name) when is_list(args) do
Enum.flat_map(args, &collect_attribute(&1, attr_name))
end
defp collect_attribute(_, _), do: []
defp module_attribute(module, attr_name) do
try do
module.__info__(:attributes)
|> Keyword.get(attr_name)
|> case do
nil -> nil
[value] -> value
value -> value
end
rescue
_ -> nil
end
end
defp source_ast(module) do
case source_file(module) do
nil ->
nil
path ->
path
|> File.read!()
|> Code.string_to_quoted!()
end
rescue
_ -> nil
end
defp source_file(module) do
try do
module.__info__(:compile)
|> Keyword.get(:source)
|> then(&if(&1, do: to_string(&1), else: nil))
rescue
_ -> nil
end
end
defp extract_ash_calls(nil), do: []
defp extract_ash_calls(ast) do
ast
|> collect_calls()
|> Enum.uniq()
end
defp collect_calls({:defmodule, _, [{:__aliases__, _, _module}, [do: body]]}) do
collect_calls(body)
end
defp collect_calls({:def, _, [{_name, _, _args}, [do: body]]}) do
collect_calls(body)
end
defp collect_calls({:defp, _, [{_name, _, _args}, [do: body]]}) do
collect_calls(body)
end
defp collect_calls(
{{:., _, [{:__aliases__, _, [:Ash]}, action]}, _,
[{:__aliases__, _, module_parts}, named_action | _]}
)
when action in [:create, :create!, :update, :update!, :destroy, :destroy!] and
is_atom(named_action) and named_action not in [true, false, nil] do
module = Module.concat(module_parts)
[
%{
"resource" => format_module(module),
"action" => :write,
"action_name" => Atom.to_string(named_action)
}
]
end
defp collect_calls(
{{:., _, [{:__aliases__, _, [:Ash]}, action]}, _, [{:__aliases__, _, module_parts} | _]}
)
when action in [
:read,
:read!,
:read_one,
:read_one!,
:get,
:get!,
:create,
:create!,
:update,
:update!,
:destroy,
:destroy!
] do
action_type =
if action in [:create, :create!, :update, :update!, :destroy, :destroy!],
do: :write,
else: :read
default_action_name =
cond do
action in [:create, :create!] -> "create"
action in [:update, :update!] -> "update"
action in [:destroy, :destroy!] -> "destroy"
true -> "read"
end
module = Module.concat(module_parts)
[
%{
"resource" => format_module(module),
"action" => action_type,
"action_name" => default_action_name
}
]
end
defp collect_calls({:|>, _, [lhs, rhs]}) do
case {leading_action_call(lhs), leading_module_alias(lhs), rhs} do
{%{"resource" => _resource} = action_call, _module_parts, {{:., _, [{:__aliases__, _, [:Ash]}, action]}, _, _args}}
when action in [:read, :read!, :read_one, :read_one!, :get, :get!, :create, :create!, :update, :update!, :destroy, :destroy!] ->
[action_call]
{_action_call, module_parts, {{:., _, [{:__aliases__, _, [:Ash]}, action]}, _, _args}}
when action in [:read, :read!, :read_one, :read_one!, :get, :get!] and module_parts != nil ->
module = Module.concat(module_parts)
[
%{
"resource" => format_module(module),
"action" => :read,
"action_name" => "read"
}
]
{_action_call, module_parts, {{:., _, [{:__aliases__, _, [:Ash]}, action]}, _, _args}}
when action in [:create, :create!, :update, :update!, :destroy, :destroy!] and
module_parts != nil ->
module = Module.concat(module_parts)
action_name =
cond do
action in [:create, :create!] -> "create"
action in [:update, :update!] -> "update"
action in [:destroy, :destroy!] -> "destroy"
end
[
%{
"resource" => format_module(module),
"action" => :write,
"action_name" => action_name
}
]
_ ->
collect_calls(lhs) ++ collect_calls(rhs)
end
end
defp collect_calls(
{{:., _, [{:__aliases__, _, [:Ash, :Changeset]}, action]}, _,
[{:__aliases__, _, module_parts}, named_action | _]}
)
when action in [:for_create, :for_update, :for_destroy] and is_atom(named_action) do
module = Module.concat(module_parts)
action_name = named_action |> to_string()
[
%{
"resource" => format_module(module),
"action" => :write,
"action_name" => action_name
}
]
end
defp collect_calls(
{{:., _, [{:__aliases__, _, [:Ash, :Query]}, :for_read]}, _,
[{:__aliases__, _, module_parts}, named_action | _]}
)
when is_atom(named_action) do
module = Module.concat(module_parts)
[
%{
"resource" => format_module(module),
"action" => :read,
"action_name" => to_string(named_action)
}
]
end
defp collect_calls(term) when is_list(term) do
Enum.flat_map(term, &collect_calls/1)
end
defp collect_calls({_, _, args}) when is_list(args) do
Enum.flat_map(args, &collect_calls/1)
end
defp collect_calls({_, _, args}) when is_tuple(args) do
collect_calls(Tuple.to_list(args))
end
defp collect_calls(_), do: []
defp leading_action_call({:|>, _, [lhs, rhs]}) do
case {leading_module_alias(lhs), rhs} do
{module_parts, {{:., _, [{:__aliases__, _, [:Ash, :Changeset]}, action]}, _, [named_action | _]}}
when module_parts != nil and action in [:for_create, :for_update, :for_destroy] and
is_atom(named_action) ->
module = Module.concat(module_parts)
%{
"resource" => format_module(module),
"action" => :write,
"action_name" => Atom.to_string(named_action)
}
{module_parts, {{:., _, [{:__aliases__, _, [:Ash, :Query]}, :for_read]}, _, [named_action | _]}}
when module_parts != nil and is_atom(named_action) ->
module = Module.concat(module_parts)
%{
"resource" => format_module(module),
"action" => :read,
"action_name" => Atom.to_string(named_action)
}
_ ->
leading_action_call(rhs) || leading_action_call(lhs)
end
end
defp leading_action_call(
{{:., _, [{:__aliases__, _, [:Ash, :Changeset]}, action]}, _,
[{:__aliases__, _, module_parts}, named_action | _]}
)
when action in [:for_create, :for_update, :for_destroy] and is_atom(named_action) do
module = Module.concat(module_parts)
action_type = if(action == :for_create, do: :write, else: :write)
%{
"resource" => format_module(module),
"action" => action_type,
"action_name" => Atom.to_string(named_action)
}
end
defp leading_action_call(
{{:., _, [{:__aliases__, _, [:Ash, :Query]}, :for_read]}, _,
[{:__aliases__, _, module_parts}, named_action | _]}
)
when is_atom(named_action) do
module = Module.concat(module_parts)
%{
"resource" => format_module(module),
"action" => :read,
"action_name" => Atom.to_string(named_action)
}
end
defp leading_action_call(_), do: nil
defp leading_module_alias({:|>, _, [lhs, _rhs]}), do: leading_module_alias(lhs)
defp leading_module_alias({:__aliases__, _, parts}), do: parts
defp leading_module_alias(_), do: nil
defp decode_value(nil), do: nil
defp decode_value(value)
when is_binary(value) or is_atom(value) or is_number(value) or is_boolean(value), do: value
defp decode_value({:__aliases__, _, parts}) when is_list(parts) do
Module.concat(parts)
end
defp decode_value({:{}, _, values}) when is_list(values) do
values
|> Enum.map(&decode_value/1)
|> List.to_tuple()
end
defp decode_value({left, right}) do
{decode_value(left), decode_value(right)}
end
defp decode_value(list) when is_list(list) do
Enum.map(list, &decode_value/1)
end
defp decode_value(_), do: nil
defp format_module(module) when is_atom(module) do
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
end
defp format_module(module), do: module
end