Current section

Files

Jump to
eyeon lib eyeon text_encoder.ex
Raw

lib/eyeon/text_encoder.ex

defmodule Eyeon.TextEncoder do
@moduledoc """
Encodes Elixir values to Ion text format.
"""
@spec encode(any(), map()) :: {:ok, iodata()} | {:error, any()}
def encode(value, _options) do
{:ok, IO.iodata_to_binary(do_encode(value))}
rescue
e -> {:error, e}
end
# nil -> null
defp do_encode(nil), do: "null"
# Typed nulls
defp do_encode({:null, type}) when is_atom(type), do: ["null.", Atom.to_string(type)]
# Booleans
defp do_encode(true), do: "true"
defp do_encode(false), do: "false"
# Integers
defp do_encode(value) when is_integer(value), do: Integer.to_string(value)
# Special floats
defp do_encode(:nan), do: "nan"
defp do_encode(:infinity), do: "+inf"
defp do_encode(:neg_infinity), do: "-inf"
# Floats
defp do_encode(value) when is_float(value) do
str = Float.to_string(value)
if String.contains?(str, "e") or String.contains?(str, "E") do
str
else
str <> "e0"
end
end
# Decimals
defp do_encode(%Decimal{sign: sign, coef: coef, exp: exp}) do
sign_str = if sign == -1, do: "-", else: ""
coef_str = if coef == :NaN, do: "nan", else: Integer.to_string(coef)
[sign_str, coef_str, "d", Integer.to_string(exp)]
end
# Timestamps
defp do_encode({:timestamp, str}) when is_binary(str), do: str
defp do_encode(%DateTime{} = dt), do: encode_datetime_string(dt)
defp do_encode(%NaiveDateTime{} = ndt), do: encode_naive_datetime_string(ndt)
defp do_encode(%Date{} = d), do: encode_date_string(d)
# Strings
defp do_encode(value) when is_binary(value) do
[?", escape_string(value), ?"]
end
# Symbols
defp do_encode({:symbol, name}) when is_binary(name) do
if symbol_needs_quoting?(name) do
[?', escape_symbol(name), ?']
else
name
end
end
# Symbol IDs
defp do_encode({:symbol_id, n}) when is_integer(n), do: ["$", Integer.to_string(n)]
# Operators
defp do_encode({:operator, str}) when is_binary(str), do: str
# Blobs
defp do_encode({:blob, binary}) when is_binary(binary) do
["{{", Base.encode64(binary), "}}"]
end
# Clobs
defp do_encode({:clob, binary}) when is_binary(binary) do
["{{\"", escape_clob(binary), "\"}}"]
end
# Lists
defp do_encode(list) when is_list(list) do
inner = Enum.intersperse(Enum.map(list, &do_encode/1), ",")
["[", inner, "]"]
end
# Structs (maps)
defp do_encode(map) when is_map(map) do
fields =
map
|> Enum.map(fn {key, value} ->
[encode_struct_key(key), ":", do_encode(value)]
end)
|> Enum.intersperse(",")
["{", fields, "}"]
end
# S-expressions
defp do_encode({:sexp, elements}) when is_list(elements) do
inner = Enum.intersperse(Enum.map(elements, &do_encode/1), " ")
["(", inner, ")"]
end
# Annotations
defp do_encode({:annotated, annotation, value}) do
[do_encode(annotation), "::", do_encode(value)]
end
# --- Timestamp string helpers (used by binary encoder) ---
@doc false
def encode_datetime_string(%DateTime{utc_offset: 0, std_offset: 0} = dt) do
pad4(dt.year) <>
"-" <>
pad2(dt.month) <>
"-" <>
pad2(dt.day) <>
"T" <>
pad2(dt.hour) <>
":" <>
pad2(dt.minute) <>
":" <> pad2(dt.second) <> encode_microsecond(dt.microsecond) <> "Z"
end
def encode_datetime_string(%DateTime{} = dt) do
pad4(dt.year) <>
"-" <>
pad2(dt.month) <>
"-" <>
pad2(dt.day) <>
"T" <>
pad2(dt.hour) <>
":" <>
pad2(dt.minute) <>
":" <>
pad2(dt.second) <> encode_microsecond(dt.microsecond) <> encode_utc_offset(dt.utc_offset)
end
@doc false
def encode_naive_datetime_string(%NaiveDateTime{} = ndt) do
pad4(ndt.year) <>
"-" <>
pad2(ndt.month) <>
"-" <>
pad2(ndt.day) <>
"T" <>
pad2(ndt.hour) <>
":" <>
pad2(ndt.minute) <>
":" <> pad2(ndt.second) <> encode_microsecond(ndt.microsecond) <> "-00:00"
end
@doc false
def encode_date_string(%Date{} = d) do
pad4(d.year) <> "-" <> pad2(d.month) <> "-" <> pad2(d.day)
end
# --- Helpers ---
@ion_keywords ~w(null true false nan)
defp symbol_needs_quoting?(""), do: true
defp symbol_needs_quoting?(name) do
name in @ion_keywords or not Regex.match?(~r/\A\$?[a-zA-Z_][a-zA-Z0-9_\$]*\z/, name)
end
defp escape_string(str) do
str
|> String.to_charlist()
|> Enum.map(fn
?\\ -> "\\\\"
?" -> "\\\""
?\n -> "\\n"
?\r -> "\\r"
?\t -> "\\t"
?\a -> "\\a"
?\b -> "\\b"
?\f -> "\\f"
?\v -> "\\v"
?\0 -> "\\0"
c when c < 0x20 -> :io_lib.format("\\x~2.16.0B", [c])
c -> <<c::utf8>>
end)
end
defp escape_symbol(str) do
str
|> String.to_charlist()
|> Enum.map(fn
?\\ -> "\\\\"
?' -> "\\'"
?\n -> "\\n"
?\r -> "\\r"
?\t -> "\\t"
?\a -> "\\a"
?\b -> "\\b"
?\f -> "\\f"
?\v -> "\\v"
?\0 -> "\\0"
c when c < 0x20 -> :io_lib.format("\\x~2.16.0B", [c])
c -> <<c::utf8>>
end)
end
defp escape_clob(binary) do
binary
|> :binary.bin_to_list()
|> Enum.map(fn
?\\ -> "\\\\"
?" -> "\\\""
?\n -> "\\n"
?\r -> "\\r"
?\t -> "\\t"
?\a -> "\\a"
?\b -> "\\b"
?\f -> "\\f"
?\v -> "\\v"
?\0 -> "\\0"
c when c >= 0x20 and c <= 0x7E -> <<c>>
c -> :io_lib.format("\\x~2.16.0B", [c])
end)
end
defp encode_struct_key(key) when is_binary(key) do
if symbol_needs_quoting?(key) do
[?', escape_symbol(key), ?']
else
key
end
end
defp pad2(n), do: String.pad_leading(Integer.to_string(n), 2, "0")
defp pad4(n), do: String.pad_leading(Integer.to_string(n), 4, "0")
defp encode_microsecond({0, 0}), do: ""
defp encode_microsecond({us, precision}) do
"." <> String.slice(String.pad_leading(Integer.to_string(us), 6, "0"), 0, precision)
end
defp encode_utc_offset(total_seconds) do
sign = if total_seconds >= 0, do: "+", else: "-"
abs_seconds = abs(total_seconds)
hours = div(abs_seconds, 3600)
minutes = div(rem(abs_seconds, 3600), 60)
"#{sign}#{pad2(hours)}:#{pad2(minutes)}"
end
end