lib/open_api/renderer/util.ex

defmodule OpenAPI.Renderer.Util do
  @moduledoc """
  Default implementation and helpers related to formatting and writing files

  This module contains the default implementations for:

    * `c:OpenAPI.Renderer.format/2`
    * `c:OpenAPI.Renderer.write/2`

  It also contains several helpers for working with ASTs, including the addition of formatting
  metadata necessary to create consistent code.
  """
  alias OpenAPI.Processor.Schema
  alias OpenAPI.Processor.Type
  alias OpenAPI.Renderer.File
  alias OpenAPI.Renderer.State

  @doc """
  Flatten and remove `nil` elements from a list of AST nodes

  This helper deals with cases in which certain sections of code are rendered conditionally, or
  with variable lengths of statements. `nil` values will be removed, and nested lists of nodes
  will be flattened.

  ## Example

      header = if condition, do: quote(do: IO.puts("List"))
      statements = for item <- items, do: quote(do: IO.puts(" * \#{item}"))

      clean_list([header, statements])

  """
  @spec clean_list(Macro.t()) :: Macro.t()
  def clean_list(nodes) do
    nodes
    |> List.flatten()
    |> Enum.reject(&is_nil/1)
  end

  @doc """
  Convert an AST into formatted code as a string

  Default implementation of `c:OpenAPI.Renderer.format/2`.

  The AST may include optional formatting metadata (ex. `delimiter`, `indentation`, or
  `end_of_expression`). It will be formatted to a line width of 98 to match the default Mix
  formatter. In addition, any `@moduledoc` or `@doc` statements that contain a newline character
  will be modified to use `\"\"\"` as the delimiter.
  """
  @doc default_implementation: true
  @spec format(State.t(), File.t()) :: iodata
  def format(_state, %File{ast: nil}), do: ""

  def format(_state, file) do
    %File{ast: ast} = file

    ast
    |> format_multiline_docs()
    |> Code.quoted_to_algebra(escape: false)
    |> Inspect.Algebra.format(98)
  end

  @doc """
  Walks the given AST and replaces `@doc` and `@moduledoc` strings with `\"\"\"` blocks if the
  contents have newlines
  """
  @spec format_multiline_docs(Macro.t()) :: Macro.t()
  def format_multiline_docs(ast_node) do
    pre = fn
      {:doc, meta, [contents]} = node, acc ->
        if is_binary(contents) and String.contains?(contents, "\n") do
          {{:doc, meta, [{:__block__, [delimiter: "\"\"\"", indentation: 2], [contents]}]}, acc}
        else
          {node, acc}
        end

      {:moduledoc, meta, [contents]} = node, acc ->
        if is_binary(contents) and String.contains?(contents, "\n") do
          {{:moduledoc, meta, [{:__block__, [delimiter: "\"\"\"", indentation: 2], [contents]}]},
           acc}
        else
          {node, acc}
        end

      node, acc ->
        {node, acc}
    end

    post = fn ast_node, acc -> {ast_node, acc} end

    {ast_node, _acc} = Macro.traverse(ast_node, nil, pre, post)
    ast_node
  end

  @doc """
  Enforce the existence of whitespace after an expression

  This helper is useful for cases in which single-line expressions should be separated from the
  following line by whitespace, but the formatter would not naturally insert that whitespace.
  For example:

      @my_attribute "Hello"
      @spec my_function :: String.t()
      def my_function, do: @my_attribute

  It may be desirable to insert whitespace following the module attribute `@my_attribute`. By
  calling this function on that node, the following will be output:

      @my_attribute "Hello"

      @spec my_function :: String.t()
      def my_function, do: @my_attribute

  If a list of nodes is given, the last node will receive the additional whitespace metadata.
  """
  @spec put_newlines(Macro.t()) :: Macro.t()
  def put_newlines({term, metadata, arguments}) do
    end_of_expression =
      Keyword.get(metadata, :end_of_expression, [])
      |> Keyword.put(:newlines, 2)

    {term, Keyword.put(metadata, :end_of_expression, end_of_expression), arguments}
  end

  def put_newlines([node]), do: [put_newlines(node)]
  def put_newlines([head | tail]), do: [head | put_newlines(tail)]

  @doc """
  Collapse nested unions and replace references with {module, type} identifiers

  This function renders most types exactly as they are expressed internally
  (ex. `{:string, :generic}`), however it transforms certain union types to be more human-readable
  and it replaces schema references with the equivalent `{Module, :type}`.
  """
  @spec to_readable_type(State.t(), Type.t()) :: term
  def to_readable_type(state, type)

  def to_readable_type(state, {:array, type}) do
    inner_type = to_readable_type(state, type)
    [inner_type]
  end

  def to_readable_type(_state, {:union, []}), do: :null
  def to_readable_type(state, {:union, [type]}), do: to_readable_type(state, type)

  def to_readable_type(state, {:union, types}) do
    types =
      unwrap_unions(types)
      |> List.flatten()
      |> Enum.map(&to_readable_type(state, &1))
      |> Enum.sort(&should_appear_in_this_order?/2)
      |> Enum.dedup()
      |> Enum.reverse()

    case types do
      [] -> :null
      [type] -> type
      types -> {:union, types}
    end
  end

  def to_readable_type(state, ref) when is_reference(ref) do
    case Map.get(state.schemas, ref) do
      %Schema{module_name: nil, type_name: type} ->
        type

      %Schema{
        context: [{:request, module, _op_function_name, _content_type}],
        module_name: module
      } ->
        :map

      %Schema{module_name: schema_module, type_name: type} ->
        module_name =
          Module.concat([
            config(state)[:base_module],
            schema_module
          ])

        {module_name, type}

      nil ->
        :map
    end
  end

  def to_readable_type(_state, type), do: type

  @doc """
  Render an internal type as a typespec

  To the best of its ability, this function constructs an accurate typespec for the internal
  type given. Note that this is somewhat lossy; for example, many distinct types of strings will
  map to the `String.t()` type.
  """
  @spec to_type(State.t(), Type.t() | {module, atom}) :: Macro.t()
  def to_type(state, type)

  # Unnatural
  def to_type(_state, :any), do: quote(do: any)
  def to_type(_state, :map), do: quote(do: map)
  def to_type(_state, :unknown), do: quote(do: any)

  # Primitives
  def to_type(_state, :boolean), do: quote(do: boolean)
  def to_type(_state, :integer), do: quote(do: integer)
  def to_type(_state, :number), do: quote(do: number)
  def to_type(_state, :null), do: quote(do: nil)

  # Strings
  def to_type(_state, {:string, :binary}), do: quote(do: binary)
  def to_type(_state, {:string, :date}), do: quote(do: Date.t())
  def to_type(_state, {:string, :date_time}), do: quote(do: DateTime.t())
  def to_type(_state, {:string, :time}), do: quote(do: Time.t())
  def to_type(_state, {:string, _}), do: quote(do: String.t())

  # Complex Types
  def to_type(state, {:array, type}) do
    inner_type = to_type(state, type)
    quote(do: [unquote(inner_type)])
  end

  def to_type(_state, {:const, literal}) when is_binary(literal), do: quote(do: String.t())
  def to_type(_state, {:const, literal}), do: quote(do: unquote(literal))

  def to_type(state, {:enum, literals}) do
    to_type(state, {:union, Enum.map(literals, &{:const, &1})})
  end

  def to_type(_state, {:union, []}), do: quote(do: nil)
  def to_type(state, {:union, [type]}), do: to_type(state, type)

  def to_type(state, {:union, types}) do
    types
    |> unwrap_unions()
    |> unwrap_enums()
    |> List.flatten()
    |> Enum.map(&to_type(state, &1))
    |> Enum.sort(&should_appear_in_this_order?/2)
    |> Enum.dedup()
    |> Enum.reduce(fn type, expression ->
      {:|, [], [type, expression]}
    end)
  end

  def to_type(state, ref) when is_reference(ref) do
    case Map.get(state.schemas, ref) do
      %Schema{module_name: nil, type_name: :map} ->
        quote do
          map
        end

      %Schema{
        context: [{:request, module, _op_function_name, _content_type}],
        module_name: module
      } ->
        quote do
          map
        end

      %Schema{
        context: [{:response, module, _op_function_name, _status, _content_type}],
        module_name: module
      } ->
        quote do
          map
        end

      %Schema{module_name: module, type_name: type} ->
        module_name =
          Module.concat([
            config(state)[:base_module],
            module
          ])

        quote do
          unquote(module_name).unquote(type)()
        end

      nil ->
        quote do
          map
        end
    end
  end

  # For types specified in configuration
  def to_type(_state, {module, type}) do
    quote do
      unquote(module).unquote(type)()
    end
  end

  @doc """
  Replace `enum` types with the equivalent list of `const` types

  This low-level helper is used by `to_type/2` when simplifying union types.
  """
  @spec unwrap_enums([Type.t()]) :: [Type.t()]
  def unwrap_enums(types) do
    types
    |> Enum.map(fn
      {:enum, literals} -> Enum.map(literals, &{:const, &1})
      type -> type
    end)
    |> List.flatten()
  end

  @doc """
  Flatten nested union types

  This low-level helper is used by `to_readable_type/2` and `to_type/2` when simplifying union
  types.
  """
  @spec unwrap_unions([Type.t()]) :: [Type.t()]
  def unwrap_unions(types) do
    types
    |> Enum.map(fn
      {:union, types} -> unwrap_unions(types)
      type -> type
    end)
    |> List.flatten()
  end

  @spec should_appear_in_this_order?(Type.t(), Type.t()) :: boolean
  defp should_appear_in_this_order?(_, nil), do: false
  defp should_appear_in_this_order?(_, :null), do: false
  defp should_appear_in_this_order?(nil, _), do: true
  defp should_appear_in_this_order?(:null, _), do: true
  defp should_appear_in_this_order?(a, b), do: a >= b

  @doc """
  Write a rendered file to the filesystem

  Default implementation of `c:OpenAPI.Renderer.write/2`.

  This implementation writes the file `contents` to the `location` and ensures an additional
  newline is included at the end of the file. It also ensures that any subdirectories are created
  prior to writing. Any failure will result in a raised error.
  """
  @doc default_implementation: true
  @spec write(State.t(), File.t()) :: :ok
  def write(_state, file) do
    %File{contents: contents, location: location} = file

    Elixir.File.mkdir_p!(Path.dirname(location))
    Elixir.File.write!(location, [contents, "\n"])
  end

  #
  # Helpers
  #

  @spec config(OpenAPI.Renderer.State.t()) :: Keyword.t()
  defp config(state) do
    %OpenAPI.Renderer.State{profile: profile} = state

    Application.get_env(:oapi_generator, profile, [])
    |> Keyword.get(:output, [])
  end
end