2025-04-16 10:03:13 -03:00

1252 lines
36 KiB
Elixir

defmodule Phoenix.Component.Declarative do
@moduledoc false
## Reserved assigns
# This list should only contain attributes that are given to components by engines
# @socket, @myself, etc. should not be listed here, as they shouldn't be given to
# function components in the first place
@reserved_assigns [:__changed__, :__slot__, :__given__, :inner_block]
@doc false
def __reserved__, do: @reserved_assigns
## Global
@global_prefixes ~w(
phx-
aria-
data-
)
@globals ~w(
accesskey
alt
autocapitalize
autofocus
class
contenteditable
contextmenu
dir
draggable
enterkeyhint
exportparts
height
hidden
id
inert
inputmode
is
itemid
itemprop
itemref
itemscope
itemtype
lang
nonce
onabort
onautocomplete
onautocompleteerror
onblur
oncancel
oncanplay
oncanplaythrough
onchange
onclick
onclose
oncontextmenu
oncuechange
ondblclick
ondrag
ondragend
ondragenter
ondragleave
ondragover
ondragstart
ondrop
ondurationchange
onemptied
onended
onerror
onfocus
oninput
oninvalid
onkeydown
onkeypress
onkeyup
onload
onloadeddata
onloadedmetadata
onloadstart
onmousedown
onmouseenter
onmouseleave
onmousemove
onmouseout
onmouseover
onmouseup
onmousewheel
onpause
onplay
onplaying
onprogress
onratechange
onreset
onresize
onscroll
onseeked
onseeking
onselect
onshow
onsort
onstalled
onsubmit
onsuspend
ontimeupdate
ontoggle
onvolumechange
onwaiting
part
placeholder
rel
role
slot
spellcheck
style
tabindex
target
title
translate
type
width
xml:base
xml:lang
)
@doc false
def __global__?(module, name, global_attr) when is_atom(module) and is_binary(name) do
includes = Keyword.get(global_attr.opts, :include, [])
if function_exported?(module, :__global__?, 1) do
module.__global__?(name) or __global__?(name) or name in includes
else
__global__?(name) or name in includes
end
end
for prefix <- @global_prefixes do
def __global__?(unquote(prefix) <> _), do: true
end
for name <- @globals do
def __global__?(unquote(name)), do: true
end
def __global__?(_), do: false
## Def overrides
@doc false
defmacro def(expr, body) do
quote do
Kernel.def(unquote(annotate_def(:def, expr)), unquote(body))
end
end
@doc false
defmacro defp(expr, body) do
quote do
Kernel.defp(unquote(annotate_def(:defp, expr)), unquote(body))
end
end
defp annotate_def(kind, expr) do
case expr do
{:when, meta, [left, right]} -> {:when, meta, [annotate_call(kind, left), right]}
left -> annotate_call(kind, left)
end
end
defp annotate_call(kind, {name, meta, [{:\\, default_meta, [left, right]}]}),
do: {name, meta, [{:\\, default_meta, [annotate_arg(kind, left), right]}]}
defp annotate_call(kind, {name, meta, [arg]}),
do: {name, meta, [annotate_arg(kind, arg)]}
defp annotate_call(_kind, left),
do: left
defp annotate_arg(kind, {:=, meta, [{name, _, ctx} = var, arg]})
when is_atom(name) and is_atom(ctx) do
{:=, meta, [var, quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg)))]}
end
defp annotate_arg(kind, {:=, meta, [arg, {name, _, ctx} = var]})
when is_atom(name) and is_atom(ctx) do
{:=, meta, [quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg))), var]}
end
defp annotate_arg(kind, {name, meta, ctx} = var) when is_atom(name) and is_atom(ctx) do
{:=, meta, [quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), _)), var]}
end
defp annotate_arg(kind, arg) do
quote(do: unquote(__MODULE__).__pattern__!(unquote(kind), unquote(arg)))
end
## Attrs/slots
@doc false
@valid_opts [:global_prefixes]
def __setup__(module, opts) do
{prefixes, invalid_opts} = Keyword.pop(opts, :global_prefixes, [])
prefix_matches =
for prefix <- prefixes do
unless String.ends_with?(prefix, "-") do
raise ArgumentError,
"global prefixes for #{inspect(module)} must end with a dash, got: #{inspect(prefix)}"
end
quote(do: {unquote(prefix) <> _, true})
end
if invalid_opts != [] do
raise ArgumentError, """
invalid options passed to #{inspect(__MODULE__)}.
The following options are supported: #{inspect(@valid_opts)}, got: #{inspect(invalid_opts)}
"""
end
Module.register_attribute(module, :__attrs__, accumulate: true)
Module.register_attribute(module, :__slot_attrs__, accumulate: true)
Module.register_attribute(module, :__slots__, accumulate: true)
Module.register_attribute(module, :__slot__, accumulate: false)
Module.register_attribute(module, :__components_calls__, accumulate: true)
Module.put_attribute(module, :__components__, %{})
Module.put_attribute(module, :on_definition, __MODULE__)
Module.put_attribute(module, :before_compile, __MODULE__)
if prefix_matches == [] do
[]
else
prefix_matches ++ [quote(do: {_, false})]
end
end
@doc false
def __slot__!(module, name, opts, line, file, block_fun) do
ensure_used!(module, line, file)
{doc, opts} = Keyword.pop(opts, :doc, nil)
unless is_binary(doc) or is_nil(doc) or doc == false do
compile_error!(line, file, ":doc must be a string or false, got: #{inspect(doc)}")
end
{required, opts} = Keyword.pop(opts, :required, false)
{validate_attrs, opts} = Keyword.pop(opts, :validate_attrs, true)
unless is_boolean(required) do
compile_error!(line, file, ":required must be a boolean, got: #{inspect(required)}")
end
Module.put_attribute(module, :__slot__, name)
slot_attrs =
try do
block_fun.()
module |> Module.get_attribute(:__slot_attrs__) |> Enum.reverse()
after
Module.put_attribute(module, :__slot__, nil)
Module.delete_attribute(module, :__slot_attrs__)
end
slot = %{
name: name,
required: required,
opts: opts,
doc: doc,
line: line,
attrs: slot_attrs,
validate_attrs: validate_attrs
}
validate_slot!(module, slot, line, file)
Module.put_attribute(module, :__slots__, slot)
:ok
end
defp validate_slot!(module, slot, line, file) do
slots = Module.get_attribute(module, :__slots__) || []
if Enum.find(slots, &(&1.name == slot.name)) do
compile_error!(line, file, """
a duplicate slot with name #{inspect(slot.name)} already exists\
""")
end
if slot.name == :inner_block and slot.attrs != [] do
compile_error!(line, file, """
cannot define attributes in a slot with name #{inspect(slot.name)}
""")
end
end
@doc false
def __attr__!(module, name, type, opts, line, file) when is_atom(name) and is_list(opts) do
ensure_used!(module, line, file)
slot = Module.get_attribute(module, :__slot__)
if name == :inner_block do
compile_error!(
line,
file,
"cannot define attribute called :inner_block. Maybe you wanted to use `slot` instead?"
)
end
if type == :global && slot do
compile_error!(line, file, "cannot define :global slot attributes")
end
if type == :global and Keyword.has_key?(opts, :required) do
compile_error!(line, file, "global attributes do not support the :required option")
end
if type == :global and Keyword.has_key?(opts, :values) do
compile_error!(line, file, "global attributes do not support the :values option")
end
if type == :global and Keyword.has_key?(opts, :examples) do
compile_error!(line, file, "global attributes do not support the :examples option")
end
if type != :global and Keyword.has_key?(opts, :include) do
compile_error!(line, file, ":include is only supported for :global attributes")
end
{doc, opts} = Keyword.pop(opts, :doc, nil)
unless is_binary(doc) or is_nil(doc) or doc == false do
compile_error!(line, file, ":doc must be a string or false, got: #{inspect(doc)}")
end
{required, opts} = Keyword.pop(opts, :required, false)
unless is_boolean(required) do
compile_error!(line, file, ":required must be a boolean, got: #{inspect(required)}")
end
if required and Keyword.has_key?(opts, :default) do
compile_error!(line, file, "only one of :required or :default must be given")
end
key = if slot, do: :__slot_attrs__, else: :__attrs__
type = validate_attr_type!(module, key, slot, name, type, line, file)
validate_attr_opts!(slot, name, opts, line, file)
if Keyword.has_key?(opts, :values) and Keyword.has_key?(opts, :examples) do
compile_error!(line, file, "only one of :values or :examples must be given")
end
if Keyword.has_key?(opts, :values) do
validate_attr_values!(slot, name, type, opts[:values], line, file)
end
if Keyword.has_key?(opts, :examples) do
validate_attr_examples!(slot, name, type, opts[:examples], line, file)
end
if Keyword.has_key?(opts, :default) do
validate_attr_default!(slot, name, type, opts, line, file)
end
attr = %{
slot: slot,
name: name,
type: type,
required: required,
opts: opts,
doc: doc,
line: line
}
Module.put_attribute(module, key, attr)
:ok
end
@builtin_types [:boolean, :integer, :float, :string, :atom, :list, :map, :global]
@valid_types [:any] ++ @builtin_types
defp validate_attr_type!(module, key, slot, name, type, line, file) when is_atom(type) do
attrs = Module.get_attribute(module, key) || []
cond do
Enum.find(attrs, fn attr -> attr.name == name end) ->
compile_error!(line, file, """
a duplicate attribute with name #{attr_slot(name, slot)} already exists\
""")
existing = type == :global && Enum.find(attrs, fn attr -> attr.type == :global end) ->
compile_error!(line, file, """
cannot define :global attribute #{inspect(name)} because one \
is already defined as #{attr_slot(existing.name, slot)}. \
Only a single :global attribute may be defined\
""")
true ->
:ok
end
case Atom.to_string(type) do
"Elixir." <> _ -> {:struct, type}
_ when type in @valid_types -> type
_ -> bad_type!(slot, name, type, line, file)
end
end
defp validate_attr_type!(_module, _key, slot, name, type, line, file) do
bad_type!(slot, name, type, line, file)
end
defp bad_type!(slot, name, type, line, file) do
compile_error!(line, file, """
invalid type #{inspect(type)} for attr #{attr_slot(name, slot)}. \
The following types are supported:
* any Elixir struct, such as URI, MyApp.User, etc
* one of #{Enum.map_join(@builtin_types, ", ", &inspect/1)}
* :any for all other types
""")
end
defp attr_slot(name, nil), do: "#{inspect(name)}"
defp attr_slot(name, slot), do: "#{inspect(name)} in slot #{inspect(slot)}"
defp validate_attr_default!(slot, name, type, opts, line, file) do
case {opts[:default], opts[:values]} do
{default, nil} ->
unless valid_value?(type, default) do
bad_default!(slot, name, type, default, line, file)
end
{default, values} ->
unless default in values do
compile_error!(line, file, """
expected the default value for attr #{attr_slot(name, slot)} to be one of #{inspect(values)}, \
got: #{inspect(default)}
""")
end
end
end
defp bad_default!(slot, name, type, default, line, file) do
compile_error!(line, file, """
expected the default value for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
got: #{inspect(default)}
""")
end
defp validate_attr_values!(slot, name, type, values, line, file) do
unless is_enumerable(values) and not Enum.empty?(values) do
compile_error!(line, file, """
:values must be a non-empty enumerable, got: #{inspect(values)}
""")
end
for value <- values,
not valid_value?(type, value),
do: bad_value!(slot, name, type, value, line, file)
end
defp is_enumerable(values) do
Enumerable.impl_for(values) != nil
end
defp bad_value!(slot, name, type, value, line, file) do
compile_error!(line, file, """
expected the values for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
got: #{inspect(value)}
""")
end
defp validate_attr_examples!(slot, name, type, examples, line, file) do
unless is_list(examples) and not Enum.empty?(examples) do
compile_error!(line, file, """
:examples must be a non-empty list, got: #{inspect(examples)}
""")
end
for example <- examples,
not valid_value?(type, example),
do: bad_example!(slot, name, type, example, line, file)
end
defp bad_example!(slot, name, type, example, line, file) do
compile_error!(line, file, """
expected the examples for attr #{attr_slot(name, slot)} to be #{type_with_article(type)}, \
got: #{inspect(example)}
""")
end
defp valid_value?(_type, nil), do: true
defp valid_value?(:any, _value), do: true
defp valid_value?(:string, value), do: is_binary(value)
defp valid_value?(:atom, value), do: is_atom(value)
defp valid_value?(:boolean, value), do: is_boolean(value)
defp valid_value?(:integer, value), do: is_integer(value)
defp valid_value?(:float, value), do: is_float(value)
defp valid_value?(:list, value), do: is_list(value)
defp valid_value?({:struct, mod}, value), do: is_struct(value, mod)
defp valid_value?(_type, _value), do: true
defp validate_attr_opts!(slot, name, opts, line, file) do
for {key, _} <- opts, message = invalid_attr_message(key, slot) do
compile_error!(line, file, """
invalid option #{inspect(key)} for attr #{attr_slot(name, slot)}. #{message}\
""")
end
end
defp invalid_attr_message(:include, inc) when is_list(inc) or is_nil(inc), do: nil
defp invalid_attr_message(:include, other),
do: "include only supports a list of attributes, got: #{inspect(other)}"
defp invalid_attr_message(:default, nil), do: nil
defp invalid_attr_message(:default, _),
do:
":default is not supported inside slot attributes, " <>
"instead use Map.get/3 with a default value when accessing a slot attribute"
defp invalid_attr_message(:required, _), do: nil
defp invalid_attr_message(:values, _), do: nil
defp invalid_attr_message(:examples, _), do: nil
defp invalid_attr_message(_key, nil),
do: "The supported options are: [:required, :default, :values, :examples]"
defp invalid_attr_message(_key, _slot),
do: "The supported options inside slots are: [:required]"
defp compile_error!(line, file, msg) do
raise CompileError, line: line, file: file, description: msg
end
defmacro __pattern__!(kind, arg) do
{name, 1} = __CALLER__.function
{_slots, attrs} = register_component!(kind, __CALLER__, name, true)
fields =
for %{name: name, required: true, type: {:struct, struct}} <- attrs do
{name, quote(do: %unquote(struct){})}
end
if fields == [] do
arg
else
quote(do: %{unquote_splicing(fields)} = unquote(arg))
end
end
@doc false
def __on_definition__(env, kind, name, args, _guards, body) do
check? = not String.starts_with?(to_string(name), "__")
cond do
check? and length(args) == 1 and body == nil ->
register_component!(kind, env, name, false)
check? ->
attrs = pop_attrs(env)
validate_misplaced_attrs!(attrs, env.file, fn ->
case length(args) do
1 ->
"could not define attributes for function #{name}/1. " <>
"Please make sure that you have `use Phoenix.Component` and that the function has no default arguments"
arity ->
"cannot declare attributes for function #{name}/#{arity}. Components must be functions with arity 1"
end
end)
slots = pop_slots(env)
validate_misplaced_slots!(slots, env.file, fn ->
case length(args) do
1 ->
"could not define slots for function #{name}/1. " <>
"Components cannot be dynamically defined or have default arguments"
arity ->
"cannot declare slots for function #{name}/#{arity}. Components must be functions with arity 1"
end
end)
true ->
:ok
end
end
@after_verify_supported Version.match?(System.version(), ">= 1.14.0-dev")
@doc false
defmacro __before_compile__(env) do
attrs = pop_attrs(env)
validate_misplaced_attrs!(attrs, env.file, fn ->
"cannot define attributes without a related function component"
end)
slots = pop_slots(env)
validate_misplaced_slots!(slots, env.file, fn ->
"cannot define slots without a related function component"
end)
components = Module.get_attribute(env.module, :__components__)
components_calls = Module.get_attribute(env.module, :__components_calls__) |> Enum.reverse()
names_and_defs =
for {name, %{kind: kind, attrs: attrs, slots: slots, line: line}} <- components do
attr_defaults =
for %{name: name, required: false, opts: opts} <- attrs,
Keyword.has_key?(opts, :default),
do: {name, Macro.escape(opts[:default])}
slot_defaults =
for %{name: name, required: false} <- slots do
{name, []}
end
defaults = attr_defaults ++ slot_defaults
{global_name, global_default} =
case Enum.find(attrs, fn attr -> attr.type == :global end) do
%{name: name, opts: opts} -> {name, Macro.escape(Keyword.get(opts, :default, %{}))}
nil -> {nil, nil}
end
attr_names = for(attr <- attrs, do: attr.name)
slot_names = for(slot <- slots, do: slot.name)
known_keys = attr_names ++ slot_names ++ @reserved_assigns
def_body =
if global_name do
quote do
{assigns, caller_globals} = Map.split(assigns, unquote(known_keys))
globals =
case assigns do
%{unquote(global_name) => explicit_global_assign} -> explicit_global_assign
%{} -> Map.merge(unquote(global_default), caller_globals)
end
merged =
%{unquote_splicing(defaults)}
|> Map.merge(assigns)
|> Map.put(:__given__, assigns)
super(Phoenix.Component.assign(merged, unquote(global_name), globals))
end
else
quote do
merged =
%{unquote_splicing(defaults)}
|> Map.merge(assigns)
|> Map.put(:__given__, assigns)
super(merged)
end
end
merge =
quote line: line do
Kernel.unquote(kind)(unquote(name)(assigns)) do
unquote(def_body)
end
end
{{name, 1}, merge}
end
{names, defs} = Enum.unzip(names_and_defs)
overridable =
if names != [] do
quote do
defoverridable unquote(names)
end
end
def_components_ast =
quote do
def __components__() do
unquote(Macro.escape(components))
end
end
def_components_calls_ast =
if components_calls != [] and @after_verify_supported do
quote do
@after_verify {__MODULE__, :__phoenix_component_verify__}
@doc false
def __phoenix_component_verify__(module) do
unquote(__MODULE__).__verify__(module, unquote(Macro.escape(components_calls)))
end
end
end
{:__block__, [], [def_components_ast, def_components_calls_ast, overridable | defs]}
end
defp register_component!(kind, env, name, check_if_defined?) do
slots = pop_slots(env)
attrs = pop_attrs(env)
cond do
slots != [] or attrs != [] ->
check_if_defined? and raise_if_function_already_defined!(env, name, slots, attrs)
register_component_doc(env, kind, slots, attrs)
for %{name: slot_name, line: line} <- slots,
Enum.find(attrs, &(&1.name == slot_name)) do
compile_error!(line, env.file, """
cannot define a slot with name #{inspect(slot_name)}, as an attribute with that name already exists\
""")
end
components =
env.module
|> Module.get_attribute(:__components__)
# Sort by name as this is used when they are validated
|> Map.put(name, %{
kind: kind,
attrs: Enum.sort_by(attrs, & &1.name),
slots: Enum.sort_by(slots, & &1.name),
line: env.line
})
Module.put_attribute(env.module, :__components__, components)
Module.put_attribute(env.module, :__last_component__, name)
{slots, attrs}
Module.get_attribute(env.module, :__last_component__) == name ->
%{slots: slots, attrs: attrs} = Module.get_attribute(env.module, :__components__)[name]
{slots, attrs}
true ->
{[], []}
end
end
# Documentation handling
defp register_component_doc(env, :def, slots, attrs) do
case Module.get_attribute(env.module, :doc) do
{_line, false} ->
:ok
{line, doc} ->
Module.put_attribute(env.module, :doc, {line, build_component_doc(doc, slots, attrs)})
nil ->
Module.put_attribute(env.module, :doc, {env.line, build_component_doc(slots, attrs)})
end
end
defp register_component_doc(_env, :defp, _slots, _attrs) do
:ok
end
defp build_component_doc(doc \\ "", slots, attrs) do
[left | right] = String.split(doc, "[INSERT LVATTRDOCS]")
IO.iodata_to_binary([
build_left_doc(left),
build_component_docs(slots, attrs),
build_right_doc(right)
])
end
defp build_left_doc("") do
[""]
end
defp build_left_doc(left) do
[left, ?\n]
end
defp build_component_docs(slots, attrs) do
case {slots, attrs} do
{[], []} ->
[]
{slots, [] = _attrs} ->
[build_slots_docs(slots)]
{[] = _slots, attrs} ->
[build_attrs_docs(attrs)]
{slots, attrs} ->
[build_attrs_docs(attrs), ?\n, build_slots_docs(slots)]
end
end
defp build_slots_docs(slots) do
[
"## Slots\n",
for slot <- slots, slot.doc != false, into: [] do
slot_attrs =
for slot_attr <- slot.attrs,
slot_attr.doc != false,
slot_attr.slot == slot.name,
do: slot_attr
[
"\n* ",
build_slot_name(slot),
build_slot_required(slot),
build_slot_doc(slot, slot_attrs)
]
end
]
end
defp build_attrs_docs(attrs) do
[
"## Attributes\n",
for attr <- attrs, attr.doc != false and attr.type != :global do
[
"\n* ",
build_attr_name(attr),
build_attr_type(attr),
build_attr_required(attr),
build_hyphen(attr),
build_attr_doc_and_default(attr, " "),
build_attr_values_or_examples(attr)
]
end,
# global always goes at the end
case Enum.find(attrs, &(&1.type === :global)) do
nil -> []
attr -> build_attr_doc_and_default(attr, " ")
end
]
end
defp build_slot_name(%{name: name}) do
["`", Atom.to_string(name), "`"]
end
defp build_slot_doc(%{doc: nil}, []) do
[]
end
defp build_slot_doc(%{doc: doc}, []) do
[" - ", build_doc(doc, " ", false)]
end
defp build_slot_doc(%{doc: nil}, slot_attrs) do
[" - Accepts attributes:\n", build_slot_attrs_docs(slot_attrs)]
end
defp build_slot_doc(%{doc: doc}, slot_attrs) do
[
" - ",
build_doc(doc, " ", true),
"Accepts attributes:\n",
build_slot_attrs_docs(slot_attrs)
]
end
defp build_slot_attrs_docs(slot_attrs) do
for slot_attr <- slot_attrs do
[
"\n * ",
build_attr_name(slot_attr),
build_attr_type(slot_attr),
build_attr_required(slot_attr),
build_hyphen(slot_attr),
build_attr_doc_and_default(slot_attr, " "),
build_attr_values_or_examples(slot_attr)
]
end
end
defp build_slot_required(%{required: true}) do
[" (required)"]
end
defp build_slot_required(_slot) do
[]
end
defp build_attr_name(%{name: name}) do
["`", Atom.to_string(name), "` "]
end
defp build_attr_type(%{type: {:struct, type}}) do
["(`", inspect(type), "`)"]
end
defp build_attr_type(%{type: type}) do
["(`", inspect(type), "`)"]
end
defp build_attr_required(%{required: true}) do
[" (required)"]
end
defp build_attr_required(_attr) do
[]
end
defp build_attr_doc_and_default(%{doc: doc, type: :global, opts: opts}, indent) do
[
"\n* Global attributes are accepted.",
if(doc, do: [" ", build_doc(doc, indent, false)], else: []),
case Keyword.get(opts, :include) do
inc when is_list(inc) and inc != [] ->
[" ", "Supports all globals plus:", " ", build_literal(inc), "."]
_ ->
[]
end
]
end
defp build_attr_doc_and_default(%{doc: doc, opts: opts}, indent) do
case Keyword.fetch(opts, :default) do
{:ok, default} ->
if doc do
[build_doc(doc, indent, true), "Defaults to ", build_literal(default), "."]
else
["Defaults to ", build_literal(default), "."]
end
:error ->
if doc, do: [build_doc(doc, indent, false)], else: []
end
end
defp build_doc(doc, indent, text_after?) do
doc = String.trim(doc)
[head | tail] = String.split(doc, ["\r\n", "\n"])
dot = if String.ends_with?(doc, "."), do: [], else: [?.]
tail =
Enum.map(tail, fn
"" -> "\n"
other -> [?\n, indent | other]
end)
case tail do
# Single line
[] when text_after? ->
[[head | tail], dot, ?\s]
[] ->
[[head | tail], dot]
# Multi-line
_ when text_after? ->
[[head | tail], "\n\n", indent]
_ ->
[[head | tail], "\n"]
end
end
defp build_attr_values_or_examples(%{opts: [values: values]}) do
["Must be one of ", build_literals_list(values, "or"), ?.]
end
defp build_attr_values_or_examples(%{opts: [examples: examples]}) do
["Examples include ", build_literals_list(examples, "and"), ?.]
end
defp build_attr_values_or_examples(_attr) do
[]
end
defp build_literals_list([literal], _condition) do
[build_literal(literal)]
end
defp build_literals_list(literals, condition) do
literals
|> Enum.map_intersperse(", ", &build_literal/1)
|> List.insert_at(-2, [condition, " "])
end
defp build_literal(literal) do
[?`, inspect(literal, charlists: :as_list), ?`]
end
defp build_hyphen(%{doc: doc}) when is_binary(doc) do
[" - "]
end
defp build_hyphen(%{opts: []}) do
[]
end
defp build_hyphen(%{opts: _opts}) do
[" - "]
end
defp build_right_doc("") do
[]
end
defp build_right_doc(right) do
[?\n, right]
end
defp validate_misplaced_attrs!(attrs, file, message_fun) do
with [%{line: first_attr_line} | _] <- attrs do
compile_error!(first_attr_line, file, message_fun.())
end
end
defp validate_misplaced_slots!(slots, file, message_fun) do
with [%{line: first_slot_line} | _] <- slots do
compile_error!(first_slot_line, file, message_fun.())
end
end
defp pop_attrs(env) do
slots = Module.delete_attribute(env.module, :__attrs__) || []
Enum.reverse(slots)
end
defp pop_slots(env) do
slots = Module.delete_attribute(env.module, :__slots__) || []
Enum.reverse(slots)
end
defp raise_if_function_already_defined!(env, name, slots, attrs) do
if Module.defines?(env.module, {name, 1}) do
{:v1, _, meta, _} = Module.get_definition(env.module, {name, 1})
with [%{line: first_attr_line} | _] <- attrs do
compile_error!(first_attr_line, env.file, """
attributes must be defined before the first function clause at line #{meta[:line]}
""")
end
with [%{line: first_slot_line} | _] <- slots do
compile_error!(first_slot_line, env.file, """
slots must be defined before the first function clause at line #{meta[:line]}
""")
end
end
end
# Verification
@doc false
def __verify__(module, component_calls) do
for %{component: {submod, fun}} = call <- component_calls,
function_exported?(submod, :__components__, 0),
component = submod.__components__()[fun],
do: verify(module, call, component)
:ok
end
defp verify(
caller_module,
%{slots: slots, attrs: attrs, root: root} = call,
%{slots: slots_defs, attrs: attrs_defs} = _component
) do
{attrs, global_attr} =
Enum.reduce(attrs_defs, {attrs, nil}, fn attr_def, {attrs, global_attr} ->
%{name: name, required: required, type: type, opts: opts} = attr_def
attr_values = Keyword.get(opts, :values, nil)
{value, attrs} = Map.pop(attrs, name)
case {type, value} do
# missing required attr
{_type, nil} when not root and required ->
message = "missing required attribute \"#{name}\" for component #{component_fa(call)}"
warn(message, call.file, call.line)
# missing optional attr, or dynamic attr
{_type, nil} when root or not required ->
:ok
# global attrs cannot be directly used
{:global, {line, _column, _type_value}} ->
message =
"global attribute \"#{name}\" in component #{component_fa(call)} may not be provided directly"
warn(message, call.file, line)
# attrs must be one of values
{_type, {line, _column, {_, type_value}}} when not is_nil(attr_values) ->
unless type_value in attr_values do
message =
"attribute \"#{name}\" in component #{component_fa(call)} must be one of #{inspect(attr_values)}, got: #{inspect(type_value)}"
warn(message, call.file, line)
end
# attrs must be of the declared type
{type, {line, _column, type_value}} ->
if value_ast_to_string = type_mismatch(type, type_value) do
message =
"attribute \"#{name}\" in component #{component_fa(call)} must be #{type_with_article(type)}, got: " <>
value_ast_to_string
[warn(message, call.file, line)]
end
end
{attrs, global_attr || (type == :global and attr_def)}
end)
for {name, {line, _column, _type_value}} <- attrs,
!(global_attr && __global__?(caller_module, Atom.to_string(name), global_attr)) do
message = "undefined attribute \"#{name}\" for component #{component_fa(call)}"
warn(message, call.file, line)
end
undefined_slots =
Enum.reduce(slots_defs, slots, fn slot_def, slots ->
%{name: slot_name, required: required, attrs: attrs, validate_attrs: validate_attrs} = slot_def
{slot_values, slots} = Map.pop(slots, slot_name)
case slot_values do
# missing required slot
nil when required ->
message = "missing required slot \"#{slot_name}\" for component #{component_fa(call)}"
warn(message, call.file, call.line)
# missing optional slot
nil ->
:ok
# slot with attributes
_ ->
slot_attr_defs = Enum.into(attrs, %{}, &{&1.name, &1})
required_attrs = for {attr_name, %{required: true}} <- slot_attr_defs, do: attr_name
for %{attrs: slot_attrs, line: slot_line, root: false} <- slot_values,
attr_name <- required_attrs,
not Map.has_key?(slot_attrs, attr_name) do
message =
"missing required attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
"for component #{component_fa(call)}"
warn(message, call.file, slot_line)
end
for %{attrs: slot_attrs} <- slot_values,
{attr_name, {line, _column, type_value}} <- slot_attrs do
case slot_attr_defs do
# slots cannot accept global attributes
%{^attr_name => %{type: :global}} ->
message =
"global attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
"for component #{component_fa(call)} may not be provided directly"
warn(message, call.file, line)
# slot attrs must be one of values
%{^attr_name => %{type: _type, opts: [values: attr_values]}}
when is_tuple(type_value) and tuple_size(type_value) == 2 ->
{_, attr_value} = type_value
unless attr_value in attr_values do
message =
"attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
"for component #{component_fa(call)} must be one of #{inspect(attr_values)}, got: " <>
inspect(attr_value)
warn(message, call.file, line)
end
# slot attrs must be of the declared type
%{^attr_name => %{type: type}} ->
if value_ast_to_string = type_mismatch(type, type_value) do
message =
"attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
"for component #{component_fa(call)} must be #{type_with_article(type)}, got: " <>
value_ast_to_string
warn(message, call.file, line)
end
# undefined slot attr
%{} ->
cond do
attr_name == :inner_block -> :ok
attrs == [] and not validate_attrs -> :ok
true ->
message =
"undefined attribute \"#{attr_name}\" in slot \"#{slot_name}\" " <>
"for component #{component_fa(call)}"
warn(message, call.file, line)
end
end
end
end
slots
end)
for {slot_name, slot_values} <- undefined_slots,
%{line: line} <- slot_values,
not implicit_inner_block?(slot_name, slots_defs) do
message = "undefined slot \"#{slot_name}\" for component #{component_fa(call)}"
warn(message, call.file, line)
end
:ok
end
defp implicit_inner_block?(slot_name, slots_defs) do
slot_name == :inner_block and length(slots_defs) > 0
end
defp type_mismatch(:any, _type_value), do: nil
defp type_mismatch(_type, :any), do: nil
defp type_mismatch(type, {type, _value}), do: nil
defp type_mismatch(:atom, {:boolean, _value}), do: nil
defp type_mismatch({:struct, _}, {:map, {:%{}, _, [{:|, _, [_, _]}]}}), do: nil
defp type_mismatch(_type, {_, value}), do: Macro.to_string(value)
defp component_fa(%{component: {mod, fun}}) do
"#{inspect(mod)}.#{fun}/1"
end
## Shared helpers
defp type_with_article({:struct, struct}), do: "a #{inspect(struct)} struct"
defp type_with_article(type) when type in [:atom, :integer], do: "an #{inspect(type)}"
defp type_with_article(type), do: "a #{inspect(type)}"
# TODO: Provide column information in error messages
defp warn(message, file, line) do
IO.warn(message, file: file, line: line)
end
defp ensure_used!(module, line, file) do
if !Module.get_attribute(module, :__attrs__) do
compile_error!(
line,
file,
"you must `use Phoenix.Component` to declare attributes. It is currently only imported."
)
end
end
end