Skip to main content

lib/rpc_elixir/codegen/tree.ex

defmodule RpcElixir.Codegen.Tree do
  @moduledoc false
  # Procedure tree (keyed by dotted-name segments, leaves hold the proc) plus
  # the two emitters that walk it: the `RpcClient` type and the
  # `createRpcClient` factory whose leaves are `rpcMethod` calls.

  import RpcElixir.Codegen.Shared

  alias RpcElixir.Codegen.Render
  alias RpcElixir.Codegen.SourceLinks

  def build_proc_tree(procedures) do
    Enum.reduce(procedures, %{}, fn proc, acc ->
      segments = String.split(proc.name, ".")
      insert_proc_tree(acc, segments, proc)
    end)
  end

  defp insert_proc_tree(tree, [segment], proc) do
    if is_map(Map.get(tree, segment)) do
      raise ~s(Codegen: name collision: procedure "#{proc.name}" cannot coexist with an existing namespace "#{segment}" — "#{segment}" would be both a leaf method and a namespace. Rename one of them.)
    end

    Map.put(tree, segment, {:leaf, proc})
  end

  defp insert_proc_tree(tree, [segment | rest], proc) do
    case Map.get(tree, segment) do
      {:leaf, existing_proc} ->
        raise ~s(Codegen: name collision: procedure "#{existing_proc.name}" cannot coexist with procedure "#{proc.name}" — "#{segment}" would be both a leaf method and a namespace. Rename one of them.)

      sub ->
        sub = sub || %{}
        Map.put(tree, segment, insert_proc_tree(sub, rest, proc))
    end
  end

  defp walk_tree(tree, name_map, depth, leaf_fn, branch_fn) do
    indent = String.duplicate("  ", depth)

    Enum.map_join(tree, "", fn {segment, node} ->
      case node do
        {:leaf, proc} ->
          leaf_fn.(segment, proc, name_map, indent)

        subtree when is_map(subtree) ->
          inner = walk_tree(subtree, name_map, depth + 1, leaf_fn, branch_fn)
          branch_fn.(segment, inner, indent)
      end
    end)
  end

  def emit_rpc_client_type(proc_tree, name_map) do
    body =
      walk_tree(proc_tree, name_map, 1, &client_type_leaf/4, &client_type_branch/3)

    "export type RpcClient = {\n#{body}};\n"
  end

  defp client_type_leaf(segment, proc, name_map, indent) do
    {input_name, output_name, error_name} = proc_type_names(proc, name_map)
    key = emit_prop_key(segment)
    doc = SourceLinks.handler_jsdoc(proc, indent)

    """
    #{doc}#{indent}#{key}: RpcMethod<#{input_name}, #{output_name}, #{proc_error_type(proc, error_name)}>;
    """
  end

  defp proc_type_names(proc, name_map) do
    base = proc_base_key(proc)

    {
      require_name!(name_map, "#{base}.Input"),
      require_name!(name_map, "#{base}.Output"),
      require_name!(name_map, "#{base}.Error")
    }
  end

  defp client_type_branch(segment, inner, indent) do
    key = emit_prop_key(segment)
    "#{indent}#{key}: {\n#{inner}#{indent}};\n"
  end

  def emit_create_rpc_client(proc_tree, name_map) do
    body = walk_tree(proc_tree, name_map, 2, &create_client_leaf/4, &create_client_branch/3)

    """
    export function createRpcClient(opts: Parameters<typeof createClient>[0]): RpcClient {
      const client: Client = createClient(opts);
      return {
    #{body}  };
    }
    """
  end

  defp create_client_leaf(segment, proc, name_map, indent) do
    {input_name, output_name, error_name} = proc_type_names(proc, name_map)
    key = emit_prop_key(segment)
    doc = SourceLinks.handler_jsdoc(proc, indent)
    codes = error_codes_literal(proc)

    """
    #{doc}#{indent}#{key}: rpcMethod<#{input_name}, #{output_name}, #{proc_error_type(proc, error_name)}>(client, #{JSON.encode!(proc.name)}, #{codes}),
    """
  end

  # The procedure's error type is its handler-declared error union (a
  # `DomainError<…>` alias) widened by any codes its middleware can `halt/2` with
  # (a `MiddlewareError<…>` arm) — `HandlerError | MiddlewareError<"unauthorized">`.
  # The two arms carry distinct `source` literals, so a client `source` check (or
  # an `isDomainError`/`isMiddlewareError` guard) narrows the union at compile
  # time. (`never | MiddlewareError<...>` collapses to `MiddlewareError<...>`, so
  # endpoints with no handler error still surface their middleware errors.)
  defp proc_error_type(proc, error_name) do
    case middleware_error_codes(proc) do
      [] -> error_name
      codes -> "#{error_name} | MiddlewareError<#{Enum.map_join(codes, " | ", &JSON.encode!/1)}>"
    end
  end

  defp error_codes_literal(proc) do
    # error_code_values yields string codes, but middleware codes are atoms — normalize
    # both to strings so a middleware code already in the handler @spec de-dups.
    values =
      (Render.error_code_values(proc.error) ++ middleware_error_codes(proc))
      |> Enum.map(&to_string/1)
      |> Enum.uniq()

    "[" <> Enum.map_join(values, ", ", &JSON.encode!/1) <> "]"
  end

  defp create_client_branch(segment, inner, indent) do
    key = emit_prop_key(segment)
    "#{indent}#{key}: {\n#{inner}#{indent}},\n"
  end
end