lib/surface.ex

defmodule Surface do
  @moduledoc """
  Surface is a component based library for **Phoenix LiveView**.

  Built on top of the new `Phoenix.LiveComponent` API, Surface provides
  a more declarative way to express and use components in Phoenix.

  Full documentation and live examples can be found at [surface-ui.org](https://surface-ui.org)

  This module defines the `~F` sigil that should be used to translate Surface
  code into Phoenix templates.

  In order to have `~F` available for any Phoenix view, add the following import to your web
  file in `lib/my_app_web.ex`:

      # lib/my_app_web.ex

      ...

      def view do
        quote do
          ...
          import Surface
        end
      end

  Additionally, use `Surface.init/1` in your mount function to initialize assigns used internally by surface:

      # A LiveView using surface templates

      defmodule PageLive do
        use Phoenix.LiveView
        import Surface

        def mount(_params, _session, socket) do
          socket = Surface.init(socket)
          ...
          {:ok, socket}
        end

        def render(assigns) do
          ~F"\""
          ...
          "\""
        end
      end

      # A LiveComponent using surface templates

      defmodule NavComponent do
        use Phoenix.LiveComponent
        import Surface

        def mount(socket) do
          socket = Surface.init(socket)
          ...
          {:ok, socket}
        end

        def render(assigns) do
          ~F"\""
          ...
          "\""
        end
      end

  ## Defining components

  To create a component you need to define a module and `use` one of the available component types:

    * `Surface.Component` - A stateless component.
    * `Surface.LiveComponent` - A live stateful component.
    * `Surface.LiveView` - A wrapper component around `Phoenix.LiveView`.
    * `Surface.MacroComponent` - A low-level component which is responsible for translating its own content at compile time.

  ## Example

      # A functional stateless component

      defmodule Button do
        use Surface.Component

        prop click, :event
        prop kind, :string, default: "is-info"

        def render(assigns) do
          ~F"\""
          <button class={"button", @kind} :on-click={@click}>
            <#slot/>
          </button>
          "\""
        end
      end

  You can visit the documentation of each type of component for further explanation and examples.
  """

  alias Surface.API
  alias Surface.Compiler.Helpers
  alias Surface.IOHelper
  alias Surface.TypeHandler

  @doc """
  Translates Surface code into Phoenix templates.
  """
  defmacro sigil_F({:<<>>, meta, [string]}, opts) do
    line_offset = if Keyword.has_key?(meta, :indentation), do: 1, else: 0
    line = __CALLER__.line + line_offset
    indentation = meta[:indentation] || 0
    column = meta[:column] || 1

    component_type = Module.get_attribute(__CALLER__.module, :component_type)

    string
    |> Surface.Compiler.compile(line, __CALLER__, __CALLER__.file,
      checks: [no_undefined_assigns: component_type != nil],
      indentation: indentation,
      column: column
    )
    |> Surface.Compiler.to_live_struct(
      debug: Enum.member?(opts, ?d),
      file: __CALLER__.file,
      line: line
    )
  end

  @doc """
  Embeds an `.sface` template as a function component.

  ## Example

      defmodule MyAppWeb.Layouts do
        use MyAppWeb, :html

        embed_sface "layouts/root.sface"
        embed_sface "layouts/app.sface"
      end

  The code above generates two functions, `root` and `app`. You can use both
  as regular function components or as layout templates.
  """
  defmacro embed_sface(relative_file) do
    file =
      __CALLER__.file
      |> Path.dirname()
      |> Path.join(relative_file)

    if File.exists?(file) do
      name = file |> Path.rootname() |> Path.basename()

      body =
        file
        |> File.read!()
        |> Surface.Compiler.compile(1, __CALLER__, file)
        |> Surface.Compiler.to_live_struct()

      quote do
        @external_resource unquote(file)
        @file unquote(file)
        def unquote(String.to_atom(name))(var!(assigns)) do
          _ = var!(assigns)
          unquote(body)
        end
      end
    else
      message = """
      could not read template "#{relative_file}": no such file or directory. \
      Trying to read file "#{file}".
      """

      IOHelper.compile_error(message, __CALLER__.file, __CALLER__.line)
    end
  end

  @doc """
  Converts the given code into Surface's AST.

  The code must be passed with the `do` block using the `~F` sigil.

  Optional `line`, `file` and `caller` metadata can be passed using `opts`.

  ## Example

      iex> [tag] =
      ...>   quote_surface do
      ...>     ~F"<div>content</div>"
      ...>   end
      ...>
      ...> tag.children
      [%Surface.AST.Literal{directives: [], value: "content"}]

  """
  defmacro quote_surface(opts \\ [], do: block) do
    {code, sigil_meta, string_meta} =
      case block do
        {:sigil_F, sigil_meta, [{:<<>>, string_meta, [code]}, _]} ->
          {code, sigil_meta, string_meta}

        _ ->
          message = "the code to be quoted must be wrapped in a `~F` sigil."
          IOHelper.compile_error(message, __CALLER__.file, __CALLER__.line)
      end

    delimiter = Keyword.fetch!(sigil_meta, :delimiter)
    line_offset = if delimiter == ~S("""), do: 1, else: 0
    default_line = Keyword.get(sigil_meta, :line) + line_offset

    line = Keyword.get(opts, :line, default_line)
    file = Keyword.get(opts, :file, __CALLER__.file)
    caller = Keyword.get(opts, :caller, quote(do: __ENV__))
    indentation = Keyword.get(string_meta, :indentation, 0)

    quote do
      Surface.Compiler.compile(unquote(code), unquote(line), unquote(var!(caller)), unquote(file),
        checks: [no_undefined_assigns: false],
        indentation: unquote(indentation),
        column: 1,
        variables: binding()
      )
    end
  end

  @doc "Retrieve a component's config based on the `key`"
  def get_config(component, key) do
    config = get_components_config()
    config[component][key]
  end

  @doc "Retrieve the component's config based on the `key`"
  defmacro get_config(key) do
    component = __CALLER__.module

    quote do
      get_config(unquote(component), unquote(key))
    end
  end

  @doc "Retrieve all component's config"
  def get_components_config() do
    Application.get_env(:surface, :components, [])
  end

  @doc "Initialize surface state in the socket"
  def init(socket) do
    socket
    |> Phoenix.Component.assign_new(:__context__, fn -> %{} end)
  end

  @doc false
  def components(opts \\ []) do
    only_current_project = Keyword.get(opts, :only_current_project, false)
    project_app = Mix.Project.config()[:app]

    apps =
      if only_current_project do
        [project_app]
      else
        :ok = Application.ensure_loaded(project_app)
        project_deps_apps = Application.spec(project_app, :applications) || []
        [project_app | project_deps_apps]
      end

    for app <- apps,
        deps_apps = Application.spec(app)[:applications] || [],
        app in [:surface, project_app] or :surface in deps_apps,
        {dir, files} = app_beams_dir_and_files(app),
        file <- files,
        List.starts_with?(file, 'Elixir.') do
      :filename.join(dir, file)
    end
    |> Enum.chunk_every(50)
    |> Task.async_stream(fn files ->
      for file <- files,
          {:ok, {_, [{_, chunk} | _]}} = :beam_lib.chunks(file, ['Attr']),
          chunk |> :erlang.binary_to_term() |> Keyword.get(:component_type) do
        file |> Path.basename(".beam") |> String.to_atom()
      end
    end)
    |> Enum.flat_map(fn {:ok, result} -> result end)
  end

  defp app_beams_dir_and_files(app) do
    dir =
      app
      |> Application.app_dir()
      |> Path.join("ebin")
      |> String.to_charlist()

    {:ok, files} = :file.list_dir(dir)
    {dir, files}
  end

  @doc false
  def default_props(module) do
    # The function_exported? call returns false if the module hasn't been loaded yet. Calling
    # module.__info__(:module) forces the module to be loaded and it turned out to be cheaper
    # then Code.ensure_loaded/1, so we use it instead to guarantee we get the props.
    props =
      if function_exported?(module, :__props__, 0) or
           (module && function_exported?(module.__info__(:module), :__props__, 0)) do
        module.__props__()
      else
        []
      end

    Enum.map(props, fn %{name: name, opts: opts} -> {name, opts[:default]} end)
  end

  @doc false
  def build_dynamic_assigns(context, static_props, dynamic_props, module, node_alias, ctx) do
    static_props =
      for {name, value} <- static_props || [] do
        {clauses, opts, original} =
          case value do
            # Value is an expression
            {_clauses, _opts, _original} ->
              value

            # Value is a literal
            _ ->
              {[value], [], nil}
          end

        {name, TypeHandler.runtime_prop_value!(module, name, clauses, opts, inspect(module), original, ctx)}
      end

    build_assigns(context, static_props, dynamic_props, module, node_alias, ctx)
  end

  @doc false
  def build_assigns(context, static_props, dynamic_props, module, node_alias, ctx) do
    static_prop_names = Keyword.keys(static_props) |> Enum.uniq()
    only_dynamic_props = Enum.reject(dynamic_props, &Enum.member?(static_prop_names, elem(&1, 0)))

    props =
      module
      |> default_props()
      |> Keyword.merge(runtime_props!(static_props, module, node_alias, ctx))
      |> Keyword.merge(runtime_props!(only_dynamic_props, module, node_alias, ctx))

    if module do
      Map.new([__context__: context] ++ props)
    else
      # Function components don't support contexts
      Map.new(props)
    end
  end

  @doc false
  def css_class(value) when is_list(value) do
    with {:ok, value} <- Surface.TypeHandler.CssClass.expr_to_value(value, [], _ctx = %{}),
         {:ok, string} <- Surface.TypeHandler.CssClass.value_to_html("class", value) do
      string
    else
      _ ->
        Surface.IOHelper.runtime_error(
          "invalid value. " <>
            "Expected a :css_class, got: #{inspect(value)}"
        )
    end
  end

  def event_to_opts(nil, _event_name) do
    []
  end

  def event_to_opts(value, event_name) do
    [{event_name, Surface.TypeHandler.Event.normalize_value(value)}]
  end

  @doc false
  defmacro prop_to_attr_opts(prop_value, prop_name) do
    quote do
      prop_to_attr_opts(unquote(prop_value), unquote(prop_name), __ENV__)
    end
  end

  @doc false
  def prop_to_attr_opts(nil, _prop_name, _caller) do
    []
  end

  def prop_to_attr_opts(prop_value, prop_name, caller) do
    module = caller.module
    meta = %{caller: caller, line: caller.line, node_alias: module}
    {type, _opts} = Surface.TypeHandler.attribute_type_and_opts(module, prop_name, meta)
    Surface.TypeHandler.attr_to_opts!(type, prop_name, prop_value)
  end

  @doc """
  Tests if a slot has been filled in.

  Useful to avoid rendering unnecessary html tags that are used to wrap an optional slot
  in combination with `:if` directive.

  ## Examples

    ```
    <div :if={slot_assigned?(:header)}>
      <#slot {@header}/>
    </div>
    ```
  """
  defmacro slot_assigned?(slot) when is_atom(slot) do
    validate_undefined_slot(slot, __CALLER__)

    quote do
      !!var!(assigns)[unquote(slot)]
    end
  end

  defmacro slot_assigned?({{:., _, [{:assigns, _, _}, slot_name]}, _, _} = slot) do
    validate_undefined_slot(slot_name, __CALLER__)

    quote do
      !!unquote(slot)
    end
  end

  defp validate_undefined_slot(slot_name, caller) do
    defined_slots =
      API.get_slots(caller.module)
      |> Enum.map(& &1.name)
      |> Enum.uniq()

    if slot_name not in defined_slots do
      similar_slot_message =
        case Helpers.did_you_mean(slot_name, defined_slots) do
          {similar, score} when score > 0.8 ->
            "\n\n  Did you mean #{inspect(to_string(similar))}?"

          _ ->
            ""
        end

      existing_slots_message =
        if defined_slots == [] do
          ""
        else
          slots =
            defined_slots
            |> Enum.map(&to_string/1)
            |> Enum.sort()

          available = Helpers.list_to_string("slot:", "slots:", slots)
          "\n\n  Available #{available}"
        end

      message = """
      no slot "#{slot_name}" defined in the component '#{caller.module}'\
      #{similar_slot_message}\
      #{existing_slots_message}\
      """

      IOHelper.warn(message, caller)
    end
  end

  defp runtime_props!(props, module, node_alias, ctx) do
    props
    |> Enum.map(fn
      {:__root__, value} -> maybe_root_prop(module, node_alias, ctx, value)
      {name, value} -> {name, value}
    end)
    |> Enum.reject(&is_nil/1)
    |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
    |> Enum.map(fn {name, values} ->
      runtime_value = TypeHandler.runtime_prop_value!(module, name, values, [], node_alias, nil, ctx)
      {name, runtime_value}
    end)
  end

  defp maybe_root_prop(module, node_alias, ctx, value) do
    case Enum.find(module.__props__(), & &1.opts[:root]) do
      nil ->
        message = """
        no root property defined for component <#{node_alias}>

        Hint: you can declare a root property using option `root: true`
        """

        IOHelper.warn(message, %Macro.Env{module: ctx.module}, ctx.file, ctx.line)
        nil

      root_prop ->
        {root_prop.name, value}
    end
  end
end