Skip to main content

lib/ash_typst_code.ex

defprotocol AshTypst.Code do
  @moduledoc """
  Protocol to support Typst code syntax.
  """

  @doc """
  Encode Elixir data structures into Typst code syntax.

  ## Examples

      iex> AshTypst.Code.encode(~U[2015-01-13 13:00:07Z], %{timezone: "America/New_York"})
      "datetime(year: 2015, month: 1, day: 13, hour: 8, minute: 0, second: 7)"

      iex> AshTypst.Code.encode(nil, %{})
      "none"

      iex> AshTypst.Code.encode(%{true: true, false: false, other: :other}, %{})
      "(\\"false\\": false, \\"true\\": true, \\"other\\": \\"other\\")"

      iex> AshTypst.Code.encode(["one", 2, 3.0], %{})
      "(\\"one\\", int(2), float(3.0))"

  The following types are supported by default:

  - `Map` -> [`dictionary`](https://typst.app/docs/reference/foundations/dictionary/)
  - `List` -> [`array`](https://typst.app/docs/reference/foundations/array/)
  - `Decimal` -> [`decimal`](https://typst.app/docs/reference/foundations/decimal/)
  - `DateTime` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
  - `NaiveDateTime` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
  - `Date` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
  - `Time` -> [`datetime`](https://typst.app/docs/reference/foundations/datetime/)
  - `Integer` -> [`int`](https://typst.app/docs/reference/foundations/int/)
  - `Float` -> [`float`](https://typst.app/docs/reference/foundations/float/)
  - `String` -> [`str`](https://typst.app/docs/reference/foundations/str/)
  - `Atom` converts one of several Typst types:
    - `nil` -> [`none`](https://typst.app/docs/reference/foundations/none/)
    - `true`/`false` -> [`bool`](https://typst.app/docs/reference/foundations/bool/)
    - All others -> [`str`](https://typst.app/docs/reference/foundations/str/)
  - `Ash.Resource` (public fields) -> [`dictionary`](https://typst.app/docs/reference/foundations/dictionary/)
  - `Ash.NotLoaded` -> [`none`](https://typst.app/docs/reference/foundations/none/)
  - `Ash.CiString` -> [`str`](https://typst.app/docs/reference/foundations/str/)

  Structs (including Ash resources) are not encoded unless they opt in. Add
  `@derive AshTypst.Code` to the module to use the built-in implementation
  (which serializes an Ash resource's public fields), or implement the protocol
  directly with `defimpl AshTypst.Code, for: MyStruct` for full control:

  ```elixir
  defmodule MyApp.Invoice do
    use Ash.Resource, domain: MyApp.Domain

    @derive AshTypst.Code

    # ...
  end
  ```

  Context must be passed through. This allows for things like dates to be formatted according to a given timezone, etc.

  If `timezone` is specified in the context, supported types will be automatically shifted to that zone. Ensure you install and configure your choice of timezone database in `config.exs`:

  ```elixir
  config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
  config :elixir, :time_zone_database, TimeZoneInfo.TimeZoneDatabase
  config :elixir, :time_zone_database, Zoneinfo.TimeZoneDatabase
  config :elixir, :time_zone_database, Tz.TimeZoneDatabase
  ```

  """
  def encode(value, context)
end

defimpl AshTypst.Code, for: Any do
  def encode(%{} = map, _context) when map_size(map) == 0, do: "(:)"

  def encode(%{__struct__: module} = map, %{struct_keys: struct_keys} = context) do
    stripped =
      case struct_keys do
        %{^module => keys} -> Map.take(map, keys)
        _ -> auto_strip(map)
      end

    AshTypst.Code.encode(stripped, context)
  end

  def encode(map, context) do
    stripped = auto_strip(map)
    AshTypst.Code.encode(stripped, context)
  end

  @struct_drop_keys [:__struct__]

  defp auto_strip(%{__struct__: module} = map) do
    if Ash.Resource.Info.resource?(module) do
      strip_ash_resource(map)
    else
      Map.drop(map, @struct_drop_keys)
    end
  end

  defp strip_ash_resource(map) do
    loadable_keys =
      map.__struct__
      |> Ash.Resource.Info.public_fields()
      |> Enum.reduce([], fn
        %{name: name, type: :attribute}, acc ->
          if name in map.__metadata__.selected, do: [name | acc], else: acc

        %{name: name}, acc ->
          [name | acc]
      end)

    Map.take(map, [:calculations, :aggregates] ++ loadable_keys)
  end
end

defimpl AshTypst.Code, for: Map do
  def encode(%{} = map, _context) when map_size(map) == 0, do: "(:)"

  def encode(map, context) do
    fields =
      Enum.map_join(map, ", ", fn
        {key, value} -> "\"#{key}\": " <> AshTypst.Code.encode(value, context)
      end)

    "(#{fields})"
  end
end

defimpl AshTypst.Code, for: List do
  def encode([], _context), do: "()"
  def encode([value], context), do: "(#{AshTypst.Code.encode(value, context)},)"

  def encode(list, context) do
    fields =
      Enum.map_join(list, ", ", fn value -> AshTypst.Code.encode(value, context) end)

    "(#{fields})"
  end
end

defimpl AshTypst.Code, for: DateTime do
  def encode(datetime, context) do
    timezone = Map.get(context, :timezone, "Etc/UTC")

    %{year: year, month: month, day: day, hour: hour, minute: minute, second: second} =
      DateTime.shift_zone!(datetime, timezone)

    "datetime(year: #{year}, month: #{month}, day: #{day}, hour: #{hour}, minute: #{minute}, second: #{second})"
  end
end

defimpl AshTypst.Code, for: NaiveDateTime do
  def encode(
        %{year: year, month: month, day: day, hour: hour, minute: minute, second: second},
        _context
      ) do
    "datetime(year: #{year}, month: #{month}, day: #{day}, hour: #{hour}, minute: #{minute}, second: #{second})"
  end
end

defimpl AshTypst.Code, for: Date do
  def encode(%{year: year, month: month, day: day}, _context) do
    "datetime(year: #{year}, month: #{month}, day: #{day})"
  end
end

defimpl AshTypst.Code, for: Time do
  def encode(
        %{hour: hour, minute: minute, second: second},
        _context
      ) do
    "datetime(hour: #{hour}, minute: #{minute}, second: #{second})"
  end
end

defimpl AshTypst.Code, for: Integer do
  def encode(integer, _context), do: "int(#{integer})"
end

defimpl AshTypst.Code, for: Float do
  def encode(float, _context), do: "float(#{float})"
end

defimpl AshTypst.Code, for: BitString do
  def encode(string, _context) do
    escaped =
      string
      |> String.replace("\\", "\\\\")
      |> String.replace("\"", "\\\"")
      |> String.replace("\n", "\\n")
      |> String.replace("\r", "\\r")
      |> String.replace("\t", "\\t")

    "\"#{escaped}\""
  end
end

defimpl AshTypst.Code, for: Atom do
  def encode(nil, _context), do: "none"
  def encode(true, _context), do: "true"
  def encode(false, _context), do: "false"
  def encode(atom, context), do: atom |> Atom.to_string() |> AshTypst.Code.encode(context)
end

defimpl AshTypst.Code, for: Decimal do
  def encode(decimal, _context), do: "decimal(\"#{decimal}\")"
end

defimpl AshTypst.Code, for: Ash.NotLoaded do
  def encode(_, _context), do: "none"
end

defimpl AshTypst.Code, for: Ash.CiString do
  def encode(%{string: string}, context), do: AshTypst.Code.encode(string, context)
end