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