Current section
Files
Jump to
Current section
Files
lib/ex_toon.ex
defmodule ExToon do
@moduledoc """
Token-Oriented Object Notation (TOON) encoder/decoder for Elixir.
Spec-conformant with TOON v3.0. Provides encode/decode with support for
all TOON array forms (inline primitive, tabular, expanded list), key folding,
path expansion, and strict-mode validation.
## Quick start
# Encode a map to TOON
{:ok, toon} = ExToon.encode(%{"name" => "Alice", "age" => 30})
# Decode TOON to a map
{:ok, value} = ExToon.decode("name: Alice\\nage: 30")
## Key ordering
Elixir maps do not preserve insertion order. For deterministic output that
matches your intended field order, pass a keyword list or a list of
`{binary, value}` tuples instead of a plain map.
"""
alias ExToon.{DecodeError, EncodeError}
# ---------------------------------------------------------------------------
# Public types
# ---------------------------------------------------------------------------
@type json_primitive :: String.t() | number() | boolean() | nil
@type json_object :: %{String.t() => json_value()}
@type json_array :: [json_value()]
@type json_value :: json_primitive() | json_object() | json_array()
@type encode_opts :: [
indent: non_neg_integer(),
delimiter: :comma | :tab | :pipe,
key_folding: :off | :safe,
flatten_depth: pos_integer() | :infinity,
replacer: (String.t(), json_value(), [String.t() | integer()] -> term())
]
@type decode_opts :: [
strict: boolean(),
expand_paths: :off | :safe
]
# ---------------------------------------------------------------------------
# Encode
# ---------------------------------------------------------------------------
@doc """
Encode an Elixir value as a TOON document string.
Returns `{:ok, string}` on success or `{:error, %ExToon.EncodeError{}}` when
the input contains an unencodable term (function, PID, reference, port) or
duplicate keys in a tuple-list object.
## Options
* `:indent` — spaces per indent level (default `2`)
* `:delimiter` — field delimiter: `:comma`, `:tab`, or `:pipe` (default `:comma`)
* `:key_folding` — collapse single-key object chains: `:off` or `:safe` (default `:off`)
* `:flatten_depth` — maximum nesting depth for array expansion (default `:infinity`)
* `:replacer` — `(key, value, path) -> value | :skip` transform callback
"""
@spec encode(term(), encode_opts()) :: {:ok, String.t()} | {:error, EncodeError.t()}
def encode(input, opts \\ []) do
# Validate outside try — unknown option keys are programmer errors (ArgumentError),
# not runtime encode failures that belong in the {:error, _} return path.
opts =
Keyword.validate!(opts, [:indent, :delimiter, :key_folding, :flatten_depth, :replacer])
try do
# encode_value/3 returns iodata() for efficiency; flatten here.
iodata = ExToon.Encoder.Core.encode_value(input, opts, [])
{:ok, IO.iodata_to_binary(iodata)}
rescue
e in EncodeError -> {:error, e}
end
end
@doc """
Encode an Elixir value as a TOON document string, raising on failure.
See `encode/2` for options. Raises `ExToon.EncodeError` when encoding fails.
"""
@spec encode!(term(), encode_opts()) :: String.t()
def encode!(input, opts \\ []) do
case encode(input, opts) do
{:ok, string} -> string
{:error, %EncodeError{} = err} -> raise err
end
end
# ---------------------------------------------------------------------------
# Decode
# ---------------------------------------------------------------------------
@doc """
Decode a TOON document string into an Elixir value.
Returns `{:ok, value}` on success or `{:error, %ExToon.DecodeError{}}` on
malformed input. CRLF line endings are normalized before processing.
## Options
* `:strict` — enable strict-mode validation (default `true`)
* `:expand_paths` — expand dotted keys into nested maps: `:off` or `:safe` (default `:off`)
"""
@spec decode(String.t(), decode_opts()) :: {:ok, json_value()} | {:error, DecodeError.t()}
def decode(input, opts \\ [])
def decode(input, opts) when is_binary(input) do
opts = Keyword.validate!(opts, [:strict, :expand_paths, :indent])
lines =
input
|> String.replace("\r\n", "\n")
|> String.split("\n")
decode_from_lines(lines, opts)
end
# Fallback clause: return a structured error instead of leaking FunctionClauseError
# when callers accidentally pass a charlist, atom, or other non-binary.
def decode(input, _opts) do
{:error,
%DecodeError{
reason: :invalid_input,
message: "input must be a binary (UTF-8 string), got: #{inspect(input)}"
}}
end
@doc """
Decode a TOON document string, raising on failure.
See `decode/2` for options. Raises `ExToon.DecodeError` when decoding fails.
"""
@spec decode!(String.t(), decode_opts()) :: json_value()
def decode!(input, opts \\ []) do
case decode(input, opts) do
{:ok, value} -> value
{:error, %DecodeError{} = err} -> raise err
end
end
# ---------------------------------------------------------------------------
# Streaming
# ---------------------------------------------------------------------------
@doc """
Encode an Elixir value as a lazy stream of TOON line binaries.
Unlike `encode/2`, this function does NOT return `{:error, _}` — encoding
failures raise `ExToon.EncodeError` during stream enumeration. Use `encode/2`
for error-safe encoding.
## Options
Same as `encode/2`.
"""
@spec encode_lines(term(), encode_opts()) :: Enumerable.t()
def encode_lines(input, opts \\ []) do
opts =
Keyword.validate!(opts, [:indent, :delimiter, :key_folding, :flatten_depth, :replacer])
ExToon.Encoder.Core.encode_lines(input, opts)
end
@doc """
Decode an `Enumerable.t()` of line binaries into an Elixir value.
Accepts any enumerable of `String.t()` lines — typically `File.stream!/1`,
`IO.stream/2`, or a plain list. Returns `{:ok, value}` or
`{:error, %ExToon.DecodeError{}}`.
## Options
Same as `decode/2`.
"""
@spec decode_from_lines(Enumerable.t(), decode_opts()) ::
{:ok, json_value()} | {:error, DecodeError.t()}
def decode_from_lines(lines, opts \\ []) do
opts = Keyword.validate!(opts, [:strict, :expand_paths, :indent])
ExToon.Decoder.Core.decode_lines(lines, opts)
end
@doc """
Decode an `Enumerable.t()` of line binaries into a list of stream events.
Returns an `Enumerable.t()` of event maps. Raises `ExToon.DecodeError` on
malformed input or strict-mode violations. This is a synchronous implementation
intended for testing and inspection of the raw parse event stream.
## Options
* `:strict` — enable strict-mode validation (default `false`)
"""
@spec decode_stream_sync(Enumerable.t(), keyword()) :: Enumerable.t()
def decode_stream_sync(lines, opts \\ []) do
opts = Keyword.validate!(opts, [:strict])
ExToon.Decoder.DecodeStreamSync.decode(lines, opts)
end
end