Skip to main content

lib/rpc_elixir/codegen.ex

defmodule RpcElixir.Codegen do
  @moduledoc """
  Generates TypeScript type definitions directly from an RPC router.
  """

  import RpcElixir.Codegen.Shared, only: [paginated_item_type: 1]

  alias RpcElixir.Codegen.Brands
  alias RpcElixir.Codegen.Render
  alias RpcElixir.Codegen.Shared
  alias RpcElixir.Codegen.Structs
  alias RpcElixir.Codegen.Tree

  @doc """
  Generates TypeScript source from a router module.

  Options:
    * `:client_import` — the import specifier for the client package
      (default: `"@elixir-ts-rpc/client"`)
    * `:out` — path of the output file. When set, handler source links in the
      generated JSDoc are emitted as absolute `file://` URIs (clickable in an
      editor); otherwise they are repo-root-relative paths.
  """
  @spec generate(module(), keyword()) :: String.t()
  def generate(router_mod, opts \\ []) do
    client_import = Keyword.get(opts, :client_import, "@elixir-ts-rpc/client")

    # The process-dictionary keys set here must be cleared even if generation
    # raises (e.g. a brand collision), or a leaked value would bleed into a
    # later generate/1 call in the same process (test/compiler processes).
    try do
      if out = Keyword.get(opts, :out) do
        Process.put(:__rpc_out_dir__, Path.dirname(Path.expand(out)))
      end

      procedures = router_mod.__procedures__()

      def_keys = Render.build_def_keys(procedures)
      name_map = Render.build_name_map(def_keys)
      proc_tree = Tree.build_proc_tree(procedures)

      struct_types = Structs.collect_struct_types(procedures, name_map)
      Brands.validate_brand_collisions!(procedures, struct_types, name_map)
      branded_block = Brands.emit_branded_types(procedures)

      parts = [
        header(client_import, procedures, branded_block),
        Structs.emit_struct_type_defs(struct_types),
        Render.emit_all_defs(procedures, name_map, struct_types),
        Tree.emit_rpc_client_type(proc_tree, name_map),
        Tree.emit_create_rpc_client(proc_tree, name_map)
      ]

      result = Enum.join(parts, "\n")
      warn_missing_abstract_code()
      result
    after
      Process.delete(:__rpc_missing_abstract_code__)
      Process.delete(:__rpc_out_dir__)
    end
  end

  defp header(client_import, procedures, branded_block) do
    paginated_type =
      if uses_paginated?(procedures) do
        "export type PaginatedResponse<T> = { items: T[]; next_cursor: string | null; has_more: boolean };\n"
      else
        ""
      end

    imports = Enum.join(client_imports(procedures), ", ")

    """
    // AUTO-GENERATED by mix rpc.gen.ts — do not edit.
    // Regenerate via: mix rpc.gen.ts --router YourApp.Router --out path/rpc.gen.ts
    import { #{imports} } from #{JSON.encode!(client_import)};

    #{paginated_type}#{branded_block}
    """
  end

  # Only import the error-shape types the generated body actually references, so the
  # output stays clean under TS's verbatim module syntax: `DomainError` when any
  # handler declares a coded error union, `MiddlewareError` when any procedure's
  # middleware advertises `rpc_error_codes/1`.
  defp client_imports(procedures) do
    base = ["createClient", "rpcMethod", "type Client", "type RpcMethod"]

    base ++
      if(any_domain_error?(procedures), do: ["type DomainError"], else: []) ++
      if(any_middleware_error?(procedures), do: ["type MiddlewareError"], else: [])
  end

  defp any_domain_error?(procedures) do
    Enum.any?(procedures, fn proc -> Render.error_code_values(proc.error) != [] end)
  end

  defp any_middleware_error?(procedures) do
    Enum.any?(procedures, fn proc -> Shared.middleware_error_codes(proc) != [] end)
  end

  defp uses_paginated?(procedures) do
    Enum.any?(procedures, fn proc ->
      paginated_ir?(proc.input) or paginated_ir?(proc.output)
    end)
  end

  defp paginated_ir?(nil), do: false

  defp paginated_ir?(%{kind: "object", fields: fields}),
    do: match?({:ok, _}, paginated_item_type(fields))

  defp paginated_ir?(%{kind: k, inner: inner})
       when k in ["nullable", "optional", "list", "custom"],
       do: paginated_ir?(inner)

  defp paginated_ir?(_), do: false

  defp warn_missing_abstract_code do
    case Process.get(:__rpc_missing_abstract_code__, []) do
      [] ->
        :ok

      modules ->
        names = modules |> Enum.reverse() |> Enum.map_join(", ", &inspect/1)

        Mix.shell().info(
          "[rpc_elixir] handler source links skipped for #{names} — compile with debug_info: true to enable"
        )
    end
  end
end