Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Dialyzer warnings to ignore
#
# Protocol fallback implementation that intentionally raises.
# This is expected behavior - the Any implementation raises Protocol.UndefinedError
# when a struct doesn't have an explicit Toon.Encoder implementation.
[
{"lib/toon/encoder.ex", :no_return}
]
54 changes: 33 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,25 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
elixir: ['1.15', '1.16', '1.17', '1.18', '1.19']
otp: ['25', '26', '27']
elixir: ["1.15", "1.16", "1.17", "1.18", "1.19"]
otp: ["25", "26", "27", "28"]
exclude:
# Elixir 1.15 doesn't support OTP 27
- elixir: '1.15'
otp: '27'
# Elixir 1.16 doesn't support OTP 27
- elixir: '1.16'
otp: '27'
# Elixir 1.15 doesn't support OTP 27+
- elixir: "1.15"
otp: "27"
- elixir: "1.15"
otp: "28"
# Elixir 1.16 doesn't support OTP 27+
- elixir: "1.16"
otp: "27"
- elixir: "1.16"
otp: "28"
# Elixir 1.17 doesn't support OTP 28
- elixir: "1.17"
otp: "28"
# Elixir 1.19 doesn't support OTP 25
- elixir: '1.19'
otp: '25'
- elixir: "1.19"
otp: "25"

steps:
- name: Checkout code
Expand Down Expand Up @@ -65,8 +72,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
elixir: ['1.19']
otp: ['27']
elixir: ["1.19"]
otp: ["28"]

steps:
- name: Checkout code
Expand All @@ -75,8 +82,8 @@ jobs:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: ${{matrix.elixir}}
otp-version: ${{matrix.otp}}
elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.otp }}

- name: Restore dependencies cache
uses: actions/cache@v4
Expand All @@ -88,22 +95,27 @@ jobs:
restore-keys: |
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-

- name: Restore PLT cache
uses: actions/cache@v4
with:
path: priv/plts
key: ${{ runner.os }}-plt-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-plt-${{ matrix.elixir }}-${{ matrix.otp }}-

- name: Install dependencies
run: mix deps.get

- name: Check formatting
run: mix format --check-formatted

- name: Run Credo
run: mix credo --strict
- name: Run quality checks
run: mix quality.ci

coverage:
name: Test Coverage
runs-on: ubuntu-latest
strategy:
matrix:
elixir: ['1.19']
otp: ['27']
elixir: ["1.19"]
otp: ["27"]

steps:
- name: Checkout code
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![Hex.pm](https://img.shields.io/hexpm/v/toon.svg)](https://hex.pm/packages/toon)
[![Documentation](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/toon)
[![Coverage Status](https://coveralls.io/repos/github/kentaro/toon_ex/badge.svg?branch=main)](https://coveralls.io/github/xu-chris/toon_ex?branch=main)

**TOON (Token-Oriented Object Notation)** encoder and decoder for Elixir.

Expand Down
7 changes: 5 additions & 2 deletions lib/toon/decode/decode.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ defmodule Toon.Decode do
alias Toon.Decode.{Options, StructuralParser}
alias Toon.DecodeError

@typedoc "Decoded TOON value"
@type decoded :: nil | boolean() | binary() | number() | list() | map()

@doc """
Decodes a TOON format string to Elixir data.

Expand Down Expand Up @@ -94,7 +97,7 @@ defmodule Toon.Decode do
iex> Toon.Decode.decode!("count: 42")
%{"count" => 42}
"""
@spec decode!(String.t(), keyword()) :: term()
@spec decode!(String.t(), keyword()) :: decoded()
def decode!(string, opts \\ []) when is_binary(string) do
case decode(string, opts) do
{:ok, result} -> result
Expand All @@ -104,7 +107,7 @@ defmodule Toon.Decode do

# Private functions

@spec do_decode(String.t(), map()) :: term()
@spec do_decode(String.t(), map()) :: decoded()
defp do_decode(string, opts) do
# Use structural parser for full TOON support
case StructuralParser.parse(string, opts) do
Expand Down
2 changes: 0 additions & 2 deletions lib/toon/decode/structural_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,6 @@ defmodule Toon.Decode.StructuralParser do
end
end

defp detect_root_type([]), do: {:object, nil}

# Parse root primitive value (single value without key)
defp parse_root_primitive([%{content: content}], _opts) do
# For root primitives, we parse directly without parser combinator
Expand Down
3 changes: 2 additions & 1 deletion lib/toon/encode/arrays.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ defmodule Toon.Encode.Arrays do
iex> IO.iodata_to_binary(result)
"items[0]:"
"""
@spec encode_empty(String.t(), String.t() | nil) :: [iodata()]
@spec encode_empty(String.t(), String.t() | nil) ::
nonempty_list(nonempty_list(binary() | nonempty_list(binary())))
def encode_empty(key, length_marker \\ nil) do
marker = format_length_marker(0, length_marker)
[[Strings.encode_key(key), "[", marker, "]", Constants.colon()]]
Expand Down
20 changes: 6 additions & 14 deletions lib/toon/encode/objects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Toon.Encode.Objects do
iex> Toon.Encode.Objects.encode(map, 0, opts)

"""
@spec encode(map(), non_neg_integer(), map()) :: iodata()
@spec encode(map(), non_neg_integer(), map()) :: [iodata()]
def encode(map, depth, opts) when is_map(map) do
writer = Writer.new(opts.indent)

Expand Down Expand Up @@ -103,22 +103,14 @@ defmodule Toon.Encode.Objects do

# Private helpers

defp append_lines(writer, [], _depth), do: writer

defp append_lines(writer, lines, depth) when is_list(lines) do
defp append_lines(writer, [header | data_rows], depth) do
# For arrays, the first line is the header at current depth
# Subsequent lines (data rows for tabular format) should be one level deeper
case lines do
[header | data_rows] ->
writer = Writer.push(writer, header, depth)

Enum.reduce(data_rows, writer, fn row, acc ->
Writer.push(acc, row, depth + 1)
end)
writer = Writer.push(writer, header, depth)

[] ->
writer
end
Enum.reduce(data_rows, writer, fn row, acc ->
Writer.push(acc, row, depth + 1)
end)
end

defp append_iodata(writer, iodata, _base_depth) do
Expand Down
11 changes: 10 additions & 1 deletion lib/toon/encode/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ defmodule Toon.Encode.Options do

alias Toon.Constants

@typedoc "Validated encoding options"
@type validated :: %{
indent: pos_integer(),
delimiter: String.t(),
length_marker: String.t() | nil,
key_order: term(),
indent_string: String.t()
}

@options_schema [
indent: [
type: :pos_integer,
Expand Down Expand Up @@ -90,7 +99,7 @@ defmodule Toon.Encode.Options do
iex> Toon.Encode.Options.validate!(indent: 4)
%{indent: 4, delimiter: ",", length_marker: nil, indent_string: " "}
"""
@spec validate!(keyword()) :: map()
@spec validate!(keyword()) :: validated()
def validate!(opts) when is_list(opts) do
case validate(opts) do
{:ok, validated} ->
Expand Down
2 changes: 1 addition & 1 deletion lib/toon/encode/strings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ defmodule Toon.Encode.Strings do
iex> Toon.Encode.Strings.encode_string("line1\\nline2") |> IO.iodata_to_binary()
~s("line1\\\\nline2")
"""
@spec encode_string(String.t(), String.t()) :: iodata()
@spec encode_string(String.t(), String.t()) :: binary() | nonempty_list(binary())
def encode_string(string, delimiter \\ ",") when is_binary(string) do
if safe_unquoted?(string, delimiter) do
string
Expand Down
17 changes: 6 additions & 11 deletions lib/toon/encoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ defprotocol Toon.Encoder do

Returns IO data that can be converted to a string.
"""
@spec encode(t, keyword()) :: iodata()
@spec encode(t, keyword()) :: iodata() | map()
def encode(value, opts)
end

Expand All @@ -42,19 +42,15 @@ defimpl Toon.Encoder, for: Any do

quote do
defimpl Toon.Encoder, for: unquote(module) do
def encode(struct, opts) do
map =
struct
|> Map.take(unquote(fields))
|> Map.new(fn {k, v} -> {to_string(k), v} end)

Toon.Encode.encode!(map, opts)
def encode(struct, _opts) do
struct
|> Map.take(unquote(fields))
|> Map.new(fn {k, v} -> {to_string(k), Toon.Utils.normalize(v)} end)
end
end
end
end

@spec encode(struct(), keyword()) :: no_return()
def encode(%_{} = struct, _opts) do
raise Protocol.UndefinedError,
protocol: @protocol,
Expand All @@ -74,7 +70,6 @@ defimpl Toon.Encoder, for: Any do
"""
end

@spec encode(term(), keyword()) :: no_return()
def encode(value, _opts) do
raise Protocol.UndefinedError,
protocol: @protocol,
Expand All @@ -87,7 +82,7 @@ defimpl Toon.Encoder, for: Any do
only

except = Keyword.get(opts, :except) ->
Map.keys(struct) -- ([:__struct__] -- except)
Map.keys(struct) -- [:__struct__ | except]

true ->
Map.keys(struct) -- [:__struct__]
Expand Down
13 changes: 13 additions & 0 deletions lib/toon/shared/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,19 @@ defmodule Toon.Utils do
Enum.map(value, &normalize/1)
end

# Structs - dispatch to Toon.Encoder protocol
def normalize(%{__struct__: _} = struct) do
result = Toon.Encoder.encode(struct, [])

# If encoder returns iodata (string), convert it to binary
# If encoder returns a map (from @derive), use it directly
case result do
binary when is_binary(binary) -> binary
map when is_map(map) -> map
iodata -> IO.iodata_to_binary(iodata)
end
end

def normalize(value) when is_map(value) do
Map.new(value, fn {k, v} ->
{to_string(k), normalize(v)}
Expand Down
5 changes: 3 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ defmodule Toon.MixProject do
:underspecs,
:unmatched_returns,
:unknown
]
],
ignore_warnings: ".dialyzer_ignore.exs"
],
aliases: aliases()
]
Expand All @@ -44,7 +45,7 @@ defmodule Toon.MixProject do
]
end

defp elixirc_paths(:test), do: ["lib"]
defp elixirc_paths(:test), do: ["lib", "test/fixtures"]
defp elixirc_paths(_), do: ["lib"]

def application do
Expand Down
28 changes: 28 additions & 0 deletions test/fixtures/test_structs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Toon.Fixtures.UserWithExcept do
@moduledoc false
@derive {Toon.Encoder, except: [:password]}
defstruct [:name, :email, :password]
end

defmodule Toon.Fixtures.CustomDate do
@moduledoc "Test struct with explicit Toon.Encoder implementation"
defstruct [:year, :month, :day]
end

defimpl Toon.Encoder, for: Toon.Fixtures.CustomDate do
def encode(%{year: y, month: m, day: d}, _opts) do
"#{y}-#{String.pad_leading(to_string(m), 2, "0")}-#{String.pad_leading(to_string(d), 2, "0")}"
end
end

defmodule Toon.Fixtures.Person do
@moduledoc false
@derive Toon.Encoder
defstruct [:name, :age]
end

defmodule Toon.Fixtures.Company do
@moduledoc false
@derive Toon.Encoder
defstruct [:name, :ceo]
end
Loading
Loading