Current section
Files
Jump to
Current section
Files
lib/eyeon.ex
defmodule Eyeon do
@moduledoc """
An Elixir library for encoding and decoding [Amazon Ion](https://amazon-ion.github.io/ion-docs/) data.
Eyeon supports both Ion text (`.ion`) and Ion binary (`.10n`) formats.
It auto-detects binary input on decode, and you choose the output format
on encode via the `:encoding` option.
## Quick start
# Decode Ion text
{:ok, value} = Eyeon.decode("{name: \\"Alice\\", scores: [98, 87, 95]}")
value["name"] #=> "Alice"
# Encode to Ion text (default)
{:ok, ion} = Eyeon.encode(%{"greeting" => "hello"})
# Encode to Ion binary
{:ok, bin} = Eyeon.encode(42, %{encoding: :binary})
## Reading and writing `.ion` files
# Write Ion text to a file
data = %{"name" => "Alice", "age" => 30, "active" => true}
File.write!("user.ion", Eyeon.encode!(data))
# Read it back
Eyeon.decode!(File.read!("user.ion"))
#=> %{"active" => true, "age" => 30, "name" => "Alice"}
## Reading and writing `.10n` (binary Ion) files
# Write Ion binary to a file
data = [1, 2, 3]
File.write!("data.10n", Eyeon.encode!(data, %{encoding: :binary}))
# Read it back — binary format is auto-detected
Eyeon.decode!(File.read!("data.10n"))
#=> [1, 2, 3]
## Ion types
Most Ion types map directly to Elixir types. Types without a native Elixir
equivalent use tagged tuples:
| Ion type | Elixir representation |
|-------------|-----------------------------------------------------|
| `null` | `nil` |
| `bool` | `true` / `false` |
| `int` | integer |
| `float` | float, `:nan`, `:infinity`, `:neg_infinity` |
| `decimal` | `Decimal.t()` |
| `timestamp` | `DateTime.t()`, `NaiveDateTime.t()`, or `Date.t()` |
| `string` | binary string |
| `symbol` | `{:symbol, name}` |
| `blob` | `{:blob, binary}` |
| `clob` | `{:clob, binary}` |
| `list` | list |
| `struct` | map with string keys |
| `sexp` | `{:sexp, list}` |
| `annotation`| `{:annotated, {:symbol, name}, value}` |
| typed null | `{:null, type}` e.g. `{:null, :int}` |
## Timestamps
By default (`:timestamp` option set to `:native`), Ion timestamps are decoded
to native Elixir date/time types:
| Ion precision | Offset known? | Elixir type |
|---|---|---|
| Full (date + time) | Yes (`Z`, `+HH:MM`, `-HH:MM`) | `DateTime.t()` |
| Full (date + time) | Unknown (`-00:00`) | `NaiveDateTime.t()` |
| Date only (`YYYY-MM-DD`) | n/a | `Date.t()` |
| Year-month (`YYYY-MMT`) | n/a | `Date.t()` (day set to 1) |
| Year only (`YYYYT`) | n/a | `Date.t()` (month and day set to 1) |
Fractional seconds beyond microseconds (6 digits) are truncated, since
Elixir's `DateTime` caps at microsecond precision.
Pass `%{timestamp: :raw}` to `decode/2` to get `{:timestamp, %Eyeon.Timestamp{}}`
tuples instead, which preserves the original Ion precision and offset semantics.
# Default: native types
Eyeon.decode!("2024-01-15T12:30:00Z")
#=> ~U[2024-01-15 12:30:00Z]
# Raw mode: precision-preserving struct
Eyeon.decode!("2024-01-15T12:30:00Z", %{timestamp: :raw})
#=> {:timestamp, %Eyeon.Timestamp{year: 2024, month: 1, day: 15, hour: 12, minute: 30, second: 0, fraction: nil, offset: 0, precision: :second}}
The encoder accepts `DateTime`, `NaiveDateTime`, `Date`, `{:timestamp, string}`,
and `{:timestamp, %Eyeon.Timestamp{}}` values in both text and binary modes.
"""
@doc """
Encodes an Elixir value to Ion format.
Returns `{:ok, iodata}` on success or `{:error, reason}` on failure.
## Options
* `:encoding` — `:text` (default) or `:binary`
## Examples
iex> Eyeon.encode(42)
{:ok, "42"}
iex> Eyeon.encode(true)
{:ok, "true"}
iex> Eyeon.encode([1, 2, 3])
{:ok, "[1,2,3]"}
iex> {:ok, bin} = Eyeon.encode(42, %{encoding: :binary})
iex> is_binary(bin)
true
"""
@spec encode(any(), map()) :: {:ok, binary()} | {:error, Exception.t()}
def encode(value, options \\ %{}) do
if Map.get(options, :encoding, :text) == :text do
Eyeon.TextEncoder.encode(value, options)
else
Eyeon.BinaryEncoder.encode(value, options)
end
end
@doc """
Encodes an Elixir value to Ion format, raising on error.
## Examples
iex> Eyeon.encode!("hello")
~s("hello")
iex> Eyeon.encode!(nil)
"null"
iex> Eyeon.encode!(%{"key" => "value"})
~s({key:"value"})
iex> Eyeon.encode!({:symbol, "foo"})
"foo"
iex> Eyeon.encode!({:blob, "data"})
"{{ZGF0YQ==}}"
"""
@spec encode!(any(), map()) :: binary()
def encode!(value, options \\ %{}) do
case encode(value, options) do
{:ok, iodata} -> iodata
{:error, err} -> raise err
end
end
@doc """
Decodes Ion text or binary data to an Elixir value.
Binary Ion (starting with the Ion Version Marker `0xE0 0x01 0x00 0xEA`)
is detected automatically; all other input is treated as Ion text.
Returns `{:ok, value}` on success or `{:error, reason}` on failure.
## Options
* `:timestamp` — `:native` (default) or `:raw`. When `:native`, timestamps
are converted to `DateTime`, `NaiveDateTime`, or `Date`. When `:raw`,
timestamps are returned as `{:timestamp, %Eyeon.Timestamp{}}` tuples
preserving the original Ion precision and offset semantics.
* `:shared_symbol_tables` — list of `Eyeon.SharedSymbolTable` structs
for resolving shared symbol table imports
## Examples
iex> Eyeon.decode("42")
{:ok, 42}
iex> Eyeon.decode("true")
{:ok, true}
iex> Eyeon.decode("[1, 2, 3]")
{:ok, [1, 2, 3]}
iex> Eyeon.decode(~s({name: "Alice", age: 30}))
{:ok, %{"age" => 30, "name" => "Alice"}}
iex> Eyeon.decode("2023-01-15T10:30:00Z")
{:ok, ~U[2023-01-15 10:30:00Z]}
iex> Eyeon.decode("2023-01-15T10:30:00Z", %{timestamp: :raw})
{:ok, {:timestamp, %Eyeon.Timestamp{year: 2023, month: 1, day: 15, hour: 10, minute: 30, second: 0, fraction: nil, offset: 0, precision: :second}}}
iex> Eyeon.decode("null.int")
{:ok, {:null, :int}}
iex> match?({:error, _}, Eyeon.decode("invalid ion !!!"))
true
"""
@spec decode(iodata(), map()) :: {:ok, any()} | {:error, any()}
def decode(iodata, options \\ %{}) do
bin = IO.iodata_to_binary(iodata)
catalog =
options
|> Map.get(:shared_symbol_tables, [])
|> Eyeon.Catalog.new()
case bin do
<<0xE0, 0x01, _minor, 0xEA, _rest::binary>> ->
Eyeon.BinaryDecoder.decode(bin, catalog, options)
_ ->
Eyeon.TextDecoder.decode(bin, catalog, options)
end
rescue
e -> {:error, Exception.message(e)}
end
@doc """
Decodes Ion text or binary data to an Elixir value, raising on error.
Accepts the same options as `decode/2`.
## Examples
iex> Eyeon.decode!("42")
42
iex> Eyeon.decode!("[1, 2, 3]")
[1, 2, 3]
iex> Eyeon.decode!(~s({name: "Alice", age: 30}))
%{"age" => 30, "name" => "Alice"}
iex> Eyeon.decode!("hello")
{:symbol, "hello"}
iex> Eyeon.decode!(<<0xE0, 0x01, 0x00, 0xEA, 0x21, 0x01>>)
1
iex> Eyeon.decode!("2024-01-15T10:30:00Z")
~U[2024-01-15 10:30:00Z]
iex> Eyeon.decode!("2024-01-15")
~D[2024-01-15]
"""
@spec decode!(iodata(), map()) :: any()
def decode!(iodata, options \\ %{}) do
case decode(iodata, options) do
{:ok, value} ->
value
{:error, err} ->
raise err
end
end
end