lib/mix/tasks/joby_kit.gen.wrapper.ex

defmodule Mix.Tasks.JobyKit.Gen.Wrapper do
  @shortdoc "Scaffold a JobyKit wrapper component, manifest entry, and preview"

  @moduledoc """
  Scaffolds a new wrapper component end-to-end:

    1. Inserts a function-component skeleton in the target module
       (`<App>Web.CoreComponents` or `<App>Web.CompositeComponents`).
       The skeleton already satisfies the wrapper contract:
       `data-component`, `attr :rest, :global`, declared slot.
    2. Inserts a `component/3` registration in `<App>Web.DesignManifest`
       (immediately before `def daisy_overrides`, falling back to the
       module's closing `end`).
    3. Inserts a `<name>_preview/1` function in
       `<App>Web.DesignPreviews`, adding an `alias` line for the host
       module if necessary.

  After the task runs, `mix joby_kit.lint` should report clean for the
  new component.

  ## Usage

      mix joby_kit.gen.wrapper modal --daisy modal
      mix joby_kit.gen.wrapper chat_panel --category composite

  ## Options

    * `--category` — `core` (default) or `composite`.
    * `--daisy` — daisyUI primitive id (see `JobyKit.DaisyCatalogue`).
      Only meaningful for `--category core`. Pre-populates the root
      element's class with the primitive's daisy class string.
    * `--manifest` — manifest module name. Defaults to
      `<App>Web.DesignManifest`.
    * `--web` — web module name. Defaults to `<App>Web` derived from
      `mix.exs`.

  ## Conventions

  Component names must be `snake_case`. The function name and the
  preview name (`<name>_preview`) must not already exist in the
  respective files; the task fails loudly rather than overwriting.

  ## Domain composites

  Domain composites (`category: :domain`) live in host-specific modules
  and aren't supported by the generator yet — register those by hand or
  use this task with `--category composite` and re-categorize the
  manifest entry afterward.
  """

  use Mix.Task

  @switches [
    category: :string,
    daisy: :string,
    manifest: :string,
    web: :string
  ]

  @impl Mix.Task
  def run(argv) do
    {opts, argv, _invalid} = OptionParser.parse(argv, switches: @switches)

    name = parse_name(argv)
    category = parse_category(Keyword.get(opts, :category, "core"))
    daisy = parse_daisy(opts[:daisy], category)

    app = Mix.Project.config()[:app] || Mix.raise("could not determine :app from mix.exs")
    web_module = opts[:web] || (Macro.camelize(to_string(app)) <> "Web")
    web_path = Macro.underscore(web_module)

    target_module_short = target_module_short(category)
    target_module = web_module <> "." <> target_module_short
    target_path = "lib/#{web_path}/components/#{Macro.underscore(target_module_short)}.ex"

    manifest_module = opts[:manifest] || (web_module <> ".DesignManifest")
    manifest_path = "lib/#{web_path}/design_manifest.ex"
    previews_path = "lib/#{web_path}/design_previews.ex"

    ensure_target_file(target_path, target_module, category)
    refuse_duplicate_function!(target_path, name)
    refuse_duplicate_manifest!(manifest_path, target_module_short, name)
    refuse_duplicate_preview!(previews_path, name)

    insert_component(target_path, target_module, name, category, daisy)
    insert_manifest_entry(manifest_path, target_module, name, category, daisy)
    insert_preview(previews_path, web_module, target_module_short, name, category)

    print_next_steps(target_module, name, category, daisy, manifest_module)
  end

  # --------------------------------------------------------------- inputs

  defp parse_name([name | _]) when is_binary(name) do
    if Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) do
      name
    else
      Mix.raise(
        "component name must be snake_case (lowercase, digits, underscores). Got: #{inspect(name)}"
      )
    end
  end

  defp parse_name(_) do
    Mix.raise("component name is required. Usage: mix joby_kit.gen.wrapper <name>")
  end

  defp parse_category("core"), do: :core
  defp parse_category("composite"), do: :composite

  defp parse_category(other),
    do:
      Mix.raise(
        "unknown --category #{inspect(other)} (valid: core, composite). Domain composites are not yet supported by the generator."
      )

  defp parse_daisy(nil, _category), do: nil

  defp parse_daisy(_daisy, :composite),
    do:
      Mix.raise("--daisy is only meaningful with --category core. Drop the flag for composites.")

  defp parse_daisy(id, :core) do
    atom = String.to_atom(id)

    JobyKit.DaisyCatalogue.categories()
    |> Enum.flat_map(& &1.components)
    |> Enum.find(&(&1.id == atom))
    |> case do
      nil ->
        Mix.raise(
          "unknown --daisy #{inspect(id)}. See JobyKit.DaisyCatalogue.categories/0 for valid ids."
        )

      component ->
        component
    end
  end

  defp target_module_short(:core), do: "CoreComponents"
  defp target_module_short(:composite), do: "CompositeComponents"

  # --------------------------------------------------------- target module

  defp ensure_target_file(path, _module, :core) do
    unless File.exists?(path) do
      Mix.raise(
        "expected core_components at #{path}, but the file does not exist. Did you run `mix joby_kit.install`?"
      )
    end
  end

  defp ensure_target_file(path, module, :composite) do
    if File.exists?(path) do
      :ok
    else
      File.mkdir_p!(Path.dirname(path))

      File.write!(path, """
      defmodule #{module} do
        @moduledoc \"\"\"
        Generic composites for this app — multi-primitive patterns reused
        across domains. Surfaces on /custom-designs.
        \"\"\"

        use Phoenix.Component
      end
      """)

      Mix.shell().info([:green, "* creating ", :reset, path])
    end
  end

  defp refuse_duplicate_function!(path, name) do
    if String.contains?(File.read!(path), "def #{name}(") do
      Mix.raise("function #{name}/1 already exists in #{path}. Pick a different name.")
    end
  end

  defp refuse_duplicate_manifest!(path, target_short, name) do
    source = File.read!(path)

    # Match either short alias form (`component(CoreComponents, :foo,`)
    # or fully qualified (`component(MyAppWeb.CoreComponents, :foo,`).
    if Regex.match?(~r/component[\(\s]+\S*#{target_short},\s*:#{name},/, source) do
      Mix.raise(
        "manifest entry for #{target_short}.#{name} already exists in #{path}. Pick a different name."
      )
    end
  end

  defp refuse_duplicate_preview!(path, name) do
    if String.contains?(File.read!(path), "def #{name}_preview(") do
      Mix.raise("preview #{name}_preview/1 already exists in #{path}. Pick a different name.")
    end
  end

  # ----------------------------------------------------- component insertion

  defp insert_component(path, target_module, name, category, daisy) do
    body = component_body(target_module, name, category, daisy)
    insert_before_final_end(path, body)
    Mix.shell().info([:green, "* updating ", :reset, "#{path} (added #{name}/1)"])
  end

  defp component_body(target_module, name, category, daisy) do
    description =
      case daisy do
        nil -> "TODO: describe the #{name} #{category_word(category)}."
        primitive -> "Wrapper around the daisyUI `#{primitive.classes}` primitive."
      end

    class_attr =
      case daisy do
        nil -> ""
        primitive -> ~s| class="#{primary_class(primitive.classes)}"|
      end

    """

      @doc \"\"\"
      #{description}
      \"\"\"
      attr :rest, :global
      slot :inner_block, required: true

      def #{name}(assigns) do
        ~H\"\"\"
        <div data-component="#{target_module}.#{name}"#{class_attr} {@rest}>
          {render_slot(@inner_block)}
        </div>
        \"\"\"
      end
    """
  end

  defp category_word(:core), do: "wrapper"
  defp category_word(:composite), do: "composite"

  # daisy classes can be compound ("collapse + radio", "chat / chat-bubble").
  # Use the first token; user can refine.
  defp primary_class(classes) do
    classes
    |> String.split(~r/[\/+]/, trim: true)
    |> hd()
    |> String.trim()
  end

  # ------------------------------------------------------ manifest insertion

  defp insert_manifest_entry(path, target_module, name, category, daisy) do
    entry = manifest_entry(target_module, name, category, daisy)
    insert_before_daisy_overrides_or_end(path, entry)
    Mix.shell().info([:green, "* updating ", :reset, "#{path} (registered :#{name})"])
  end

  defp manifest_entry(target_module, name, category, daisy) do
    daisy_line =
      case daisy do
        nil -> ""
        primitive -> ~s|    daisy_basis: "#{primitive.classes}",\n|
      end

    """

      component(#{target_module}, :#{name},
        category: :#{category},
    #{daisy_line |> String.trim_trailing("\n")}
        summary: "TODO: describe.",
        preview: &DesignPreviews.#{name}_preview/1
      )
    """
    |> collapse_blank_lines()
  end

  defp collapse_blank_lines(text) do
    Regex.replace(~r/\n\s*\n\s*\n/, text, "\n\n")
  end

  defp insert_before_daisy_overrides_or_end(path, content) do
    source = File.read!(path)

    case Regex.run(~r/\n(\s*@doc\s+"""\n.*?"""\n)?(\s*def daisy_overrides do)/s, source,
           return: :index
         ) do
      [{full_start, _}, _doc_match, _def_match] when full_start > 0 ->
        before = binary_part(source, 0, full_start)
        rest = binary_part(source, full_start, byte_size(source) - full_start)
        File.write!(path, String.trim_trailing(before, "\n") <> "\n" <> content <> rest)

      _ ->
        insert_before_final_end(path, content)
    end
  end

  # ------------------------------------------------------- preview insertion

  defp insert_preview(path, web_module, target_short, name, category) do
    if category == :composite, do: ensure_alias(path, web_module, target_short)
    body = preview_body(target_short, name, category)
    insert_before_final_end(path, body)
    Mix.shell().info([:green, "* updating ", :reset, "#{path} (added #{name}_preview/1)"])
  end

  # Core wrappers are imported into design_previews via `use <App>Web, :html`,
  # so the preview can use the `<.name>` form. Composites need an aliased
  # call site.
  defp preview_body("CoreComponents", name, :core) do
    """

      def #{name}_preview(assigns) do
        ~H\"\"\"
        <.#{name}>#{name}</.#{name}>
        \"\"\"
      end
    """
  end

  defp preview_body(target_short, name, _category) do
    """

      def #{name}_preview(assigns) do
        ~H\"\"\"
        <#{target_short}.#{name}>#{name}</#{target_short}.#{name}>
        \"\"\"
      end
    """
  end

  defp ensure_alias(path, web_module, target_short) do
    source = File.read!(path)
    alias_line = "alias #{web_module}.#{target_short}"
    group_re = ~r/alias\s+#{Regex.escape(web_module)}\.\{([^}]*)\}/

    cond do
      String.contains?(source, alias_line) ->
        :ok

      Regex.match?(
        ~r/alias\s+#{Regex.escape(web_module)}\.\{[^}]*\b#{target_short}\b[^}]*\}/,
        source
      ) ->
        :ok

      Regex.match?(group_re, source) ->
        new_source =
          Regex.replace(group_re, source, fn _full, members ->
            extended =
              members
              |> String.split(",")
              |> Enum.map(&String.trim/1)
              |> Enum.reject(&(&1 == ""))
              |> Kernel.++([target_short])
              |> Enum.uniq()
              |> Enum.sort()
              |> Enum.join(", ")

            "alias #{web_module}.{#{extended}}"
          end, global: false)

        File.write!(path, new_source)

      true ->
        # Insert after the first `use ...` line. For design_previews this is
        # `use <App>Web, :html`; for design_manifest it's
        # `use JobyKit.Manifest`. Either anchors a sensible alias position.
        case Regex.run(~r/use [^\n]*\n/, source, return: :index) do
          [{start, len}] ->
            before = binary_part(source, 0, start + len)
            after_ = binary_part(source, start + len, byte_size(source) - start - len)
            File.write!(path, before <> "\n  " <> alias_line <> "\n" <> after_)

          _ ->
            Mix.shell().error(
              "could not find a `use ...` line in #{path}; please add `#{alias_line}` manually."
            )
        end
    end
  end

  # ------------------------------------------------- generic file insertion

  defp insert_before_final_end(path, content) do
    source = File.read!(path)

    case Regex.run(~r/\A(.*\n)(end\s*\z)/s, source, capture: :all_but_first) do
      [body, trailer] ->
        File.write!(path, body <> String.trim_trailing(content, "\n") <> "\n" <> trailer)

      nil ->
        Mix.raise("could not find module-closing `end` in #{path}.")
    end
  end

  # ----------------------------------------------------------- next-steps

  defp print_next_steps(target_module, name, category, daisy, manifest_module) do
    Mix.shell().info("")
    Mix.shell().info([:cyan, "Scaffolded:", :reset])
    Mix.shell().info("  #{target_module}.#{name}/1   (category: :#{category})")
    if daisy, do: Mix.shell().info("  daisyUI basis: #{daisy.classes}")
    Mix.shell().info("  manifest: #{manifest_module}")
    Mix.shell().info("")
    Mix.shell().info([:cyan, "Next:", :reset])
    Mix.shell().info("  1. Fill in the @doc summary and the body of the wrapper.")
    Mix.shell().info("  2. Update the manifest summary: \"TODO: describe.\".")
    Mix.shell().info("  3. Flesh out the preview.")
    Mix.shell().info("  4. mix joby_kit.lint   # confirm contract holds")
  end
end