From e21e68ae910628d61f325601e4c9483c6181581a Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Mon, 19 Jan 2026 08:49:40 +0100 Subject: [PATCH 1/8] fix: remove parantheses to make except work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expression `[:__struct__] -- except` evaluates to `[:__struct__]` (since except typically doesn't contain `:__struct__)`, so the final result is `Map.keys(struct) -- [:__struct__]` — the except fields are never removed. This especially happens when deriving from Ecto schemas and ignoring the `__meta__` like this: `@derive {Toon.Encoder, except: [:__meta__]}` This solves it by removing the parentheses. The PR adds necessary tests to make sure this is fixed. --- lib/toon/encoder.ex | 2 +- mix.exs | 2 +- test/fixtures/test_structs.ex | 4 ++++ test/toon/encoder_test.exs | 20 ++++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/test_structs.ex create mode 100644 test/toon/encoder_test.exs diff --git a/lib/toon/encoder.ex b/lib/toon/encoder.ex index d3aa8b7..f19ca1f 100644 --- a/lib/toon/encoder.ex +++ b/lib/toon/encoder.ex @@ -87,7 +87,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__] diff --git a/mix.exs b/mix.exs index 0adb77b..60bb432 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,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 diff --git a/test/fixtures/test_structs.ex b/test/fixtures/test_structs.ex new file mode 100644 index 0000000..63d0453 --- /dev/null +++ b/test/fixtures/test_structs.ex @@ -0,0 +1,4 @@ +defmodule Toon.Fixtures.UserWithExcept do + @derive {Toon.Encoder, except: [:password]} + defstruct [:name, :email, :password] +end diff --git a/test/toon/encoder_test.exs b/test/toon/encoder_test.exs new file mode 100644 index 0000000..9bac673 --- /dev/null +++ b/test/toon/encoder_test.exs @@ -0,0 +1,20 @@ +defmodule Toon.EncoderTest do + use ExUnit.Case, async: true + + alias Toon.Fixtures.UserWithExcept + + describe "fields_to_encode/2 with except option" do + @user_attrs %{name: "Alice", email: "a@b.com", password: "secret"} + + test "excludes specified fields from encoding" do + user = struct(UserWithExcept, @user_attrs) + + encoded = user |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() + {:ok, decoded} = Toon.decode(encoded) + + assert Map.has_key?(decoded, "name") == true + assert Map.has_key?(decoded, "email") == true + assert Map.has_key?(decoded, "password") == false + end + end +end From 03f1d5de5bb5e902b5144a159f4cec6af0069c97 Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 10:43:04 +0100 Subject: [PATCH 2/8] fix: dispatch to Toon.Encoder protocol for structs in normalize/1 Previously, structs would fall through to the map clause in normalize/1, which attempted to enumerate them and failed with "Enumerable not implemented". This fix adds a struct clause before the map clause that dispatches to the Toon.Encoder protocol, mirroring how Jason handles struct encoding. Co-Authored-By: Claude Opus 4.5 --- lib/toon/shared/utils.ex | 7 +++++++ test/fixtures/test_structs.ex | 11 +++++++++++ test/toon/encoder_test.exs | 24 +++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/toon/shared/utils.ex b/lib/toon/shared/utils.ex index 87d2f39..060c505 100644 --- a/lib/toon/shared/utils.ex +++ b/lib/toon/shared/utils.ex @@ -194,6 +194,13 @@ defmodule Toon.Utils do Enum.map(value, &normalize/1) end + # # Structs - dispatch to Toon.Encoder protocol + def normalize(%{__struct__: _} = struct) do + struct + |> Toon.Encoder.encode([]) + |> IO.iodata_to_binary() + end + def normalize(value) when is_map(value) do Map.new(value, fn {k, v} -> {to_string(k), normalize(v)} diff --git a/test/fixtures/test_structs.ex b/test/fixtures/test_structs.ex index 63d0453..dd2a651 100644 --- a/test/fixtures/test_structs.ex +++ b/test/fixtures/test_structs.ex @@ -2,3 +2,14 @@ defmodule Toon.Fixtures.UserWithExcept do @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 diff --git a/test/toon/encoder_test.exs b/test/toon/encoder_test.exs index 9bac673..08ece4d 100644 --- a/test/toon/encoder_test.exs +++ b/test/toon/encoder_test.exs @@ -1,7 +1,7 @@ defmodule Toon.EncoderTest do use ExUnit.Case, async: true - alias Toon.Fixtures.UserWithExcept + alias Toon.Fixtures.{CustomDate, UserWithExcept} describe "fields_to_encode/2 with except option" do @user_attrs %{name: "Alice", email: "a@b.com", password: "secret"} @@ -17,4 +17,26 @@ defmodule Toon.EncoderTest do assert Map.has_key?(decoded, "password") == false end end + + describe "Toon.Utils.normalize/1 dispatches to Toon.Encoder for structs" do + test "dispatches to explicit Toon.Encoder implementation" do + date = %CustomDate{year: 2024, month: 1, day: 15} + + # Direct encoder call + encoded_directly = date |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() + + # normalize/1 should produce identical output + assert Toon.Utils.normalize(date) == encoded_directly + end + + test "dispatches to @derive Toon.Encoder" do + user = %UserWithExcept{name: "Bob", email: "bob@test.com", password: "secret"} + + # Direct encoder call + encoded_directly = user |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() + + # normalize/1 should produce identical output + assert Toon.Utils.normalize(user) == encoded_directly + end + end end From 85f9f1af2422d53a41a8b656bf6310a29c2fe826 Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 14:29:46 +0100 Subject: [PATCH 3/8] fix(encoder): return normalized maps from derived encoders Prevents double-escaping of nested structs by having derived encoders return maps instead of TOON strings. The actual TOON encoding now happens once at the top level. Co-Authored-By: Claude Opus 4.5 --- lib/toon/encoder.ex | 11 +++---- lib/toon/shared/utils.ex | 14 +++++--- test/fixtures/test_structs.ex | 10 ++++++ test/nested_struct_test.exs | 60 +++++++++++++++++++++++++++++++++++ test/toon/encoder_test.exs | 28 ++++++++++------ 5 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 test/nested_struct_test.exs diff --git a/lib/toon/encoder.ex b/lib/toon/encoder.ex index f19ca1f..2e7156a 100644 --- a/lib/toon/encoder.ex +++ b/lib/toon/encoder.ex @@ -42,13 +42,10 @@ 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 diff --git a/lib/toon/shared/utils.ex b/lib/toon/shared/utils.ex index 060c505..81251d2 100644 --- a/lib/toon/shared/utils.ex +++ b/lib/toon/shared/utils.ex @@ -194,11 +194,17 @@ defmodule Toon.Utils do Enum.map(value, &normalize/1) end - # # Structs - dispatch to Toon.Encoder protocol + # Structs - dispatch to Toon.Encoder protocol def normalize(%{__struct__: _} = struct) do - struct - |> Toon.Encoder.encode([]) - |> IO.iodata_to_binary() + 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 diff --git a/test/fixtures/test_structs.ex b/test/fixtures/test_structs.ex index dd2a651..8a1bc90 100644 --- a/test/fixtures/test_structs.ex +++ b/test/fixtures/test_structs.ex @@ -13,3 +13,13 @@ defimpl Toon.Encoder, for: Toon.Fixtures.CustomDate 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 + @derive Toon.Encoder + defstruct [:name, :age] +end + +defmodule Toon.Fixtures.Company do + @derive Toon.Encoder + defstruct [:name, :ceo] +end diff --git a/test/nested_struct_test.exs b/test/nested_struct_test.exs new file mode 100644 index 0000000..ddd576e --- /dev/null +++ b/test/nested_struct_test.exs @@ -0,0 +1,60 @@ +defmodule Toon.NestedStructTest do + use ExUnit.Case, async: true + + alias Toon.Fixtures.{Person, Company} + + describe "nested struct encoding" do + test "encodes and decodes nested structs correctly" do + person = %Person{name: "John", age: 30} + company = %Company{name: "Acme", ceo: person} + + # Encode the nested struct + encoded = Toon.encode!(company) + + # Should produce TOON format + assert is_binary(encoded) + + # Decode and verify + {:ok, decoded} = Toon.decode(encoded) + + # Verify structure + assert decoded["name"] == "Acme" + assert decoded["ceo"]["name"] == "John" + assert decoded["ceo"]["age"] == 30 + end + + test "normalizes nested structs to maps" do + person = %Person{name: "Jane", age: 25} + company = %Company{name: "TechCo", ceo: person} + + # normalize should convert nested structs to maps + normalized = Toon.Utils.normalize(company) + + assert is_map(normalized) + assert normalized["name"] == "TechCo" + assert is_map(normalized["ceo"]) + assert normalized["ceo"]["name"] == "Jane" + assert normalized["ceo"]["age"] == 25 + end + + test "handles deeply nested structs" do + person1 = %Person{name: "Alice", age: 35} + company1 = %Company{name: "StartupA", ceo: person1} + + person2 = %Person{name: "Bob", age: 40} + company2 = %Company{name: "StartupB", ceo: person2} + + # Create a list of companies (testing nested structs in lists) + data = %{"companies" => [company1, company2]} + + encoded = Toon.encode!(data) + {:ok, decoded} = Toon.decode(encoded) + + assert length(decoded["companies"]) == 2 + assert decoded["companies"] |> Enum.at(0) |> Map.get("name") == "StartupA" + assert decoded["companies"] |> Enum.at(0) |> Map.get("ceo") |> Map.get("name") == "Alice" + assert decoded["companies"] |> Enum.at(1) |> Map.get("name") == "StartupB" + assert decoded["companies"] |> Enum.at(1) |> Map.get("ceo") |> Map.get("name") == "Bob" + end + end +end diff --git a/test/toon/encoder_test.exs b/test/toon/encoder_test.exs index 08ece4d..3b86837 100644 --- a/test/toon/encoder_test.exs +++ b/test/toon/encoder_test.exs @@ -9,12 +9,18 @@ defmodule Toon.EncoderTest do test "excludes specified fields from encoding" do user = struct(UserWithExcept, @user_attrs) - encoded = user |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() - {:ok, decoded} = Toon.decode(encoded) + # Derived encoder now returns a normalized map + encoded_map = Toon.Encoder.encode(user, []) - assert Map.has_key?(decoded, "name") == true - assert Map.has_key?(decoded, "email") == true - assert Map.has_key?(decoded, "password") == false + assert Map.has_key?(encoded_map, "name") == true + assert Map.has_key?(encoded_map, "email") == true + assert Map.has_key?(encoded_map, "password") == false + + # Can still be encoded to TOON format + toon_string = Toon.encode!(encoded_map) + {:ok, decoded} = Toon.decode(toon_string) + + assert decoded == encoded_map end end @@ -22,21 +28,23 @@ defmodule Toon.EncoderTest do test "dispatches to explicit Toon.Encoder implementation" do date = %CustomDate{year: 2024, month: 1, day: 15} - # Direct encoder call + # Explicit encoder still returns iodata/string encoded_directly = date |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() + assert encoded_directly == "2024-01-15" - # normalize/1 should produce identical output + # normalize/1 should produce identical output for explicit encoders assert Toon.Utils.normalize(date) == encoded_directly end test "dispatches to @derive Toon.Encoder" do user = %UserWithExcept{name: "Bob", email: "bob@test.com", password: "secret"} - # Direct encoder call - encoded_directly = user |> Toon.Encoder.encode([]) |> IO.iodata_to_binary() + # Derived encoder returns normalized map + encoded_map = Toon.Encoder.encode(user, []) # normalize/1 should produce identical output - assert Toon.Utils.normalize(user) == encoded_directly + assert Toon.Utils.normalize(user) == encoded_map + assert encoded_map == %{"name" => "Bob", "email" => "bob@test.com"} end end end From 1d6ab2350613c6fde719867833a9b8e4fcace4eb Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 14:38:18 +0100 Subject: [PATCH 4/8] chore: fix credo issues and update encoder spec - Add @moduledoc false to test fixture modules - Fix alias ordering in nested_struct_test.exs - Update @spec to allow map() return from derived encoders Co-Authored-By: Claude Opus 4.5 --- lib/toon/encoder.ex | 2 +- test/fixtures/test_structs.ex | 3 +++ test/nested_struct_test.exs | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/toon/encoder.ex b/lib/toon/encoder.ex index 2e7156a..f6663a3 100644 --- a/lib/toon/encoder.ex +++ b/lib/toon/encoder.ex @@ -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 diff --git a/test/fixtures/test_structs.ex b/test/fixtures/test_structs.ex index 8a1bc90..46087f8 100644 --- a/test/fixtures/test_structs.ex +++ b/test/fixtures/test_structs.ex @@ -1,4 +1,5 @@ defmodule Toon.Fixtures.UserWithExcept do + @moduledoc false @derive {Toon.Encoder, except: [:password]} defstruct [:name, :email, :password] end @@ -15,11 +16,13 @@ defimpl Toon.Encoder, for: Toon.Fixtures.CustomDate do 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 diff --git a/test/nested_struct_test.exs b/test/nested_struct_test.exs index ddd576e..dd8304c 100644 --- a/test/nested_struct_test.exs +++ b/test/nested_struct_test.exs @@ -1,7 +1,7 @@ defmodule Toon.NestedStructTest do use ExUnit.Case, async: true - alias Toon.Fixtures.{Person, Company} + alias Toon.Fixtures.{Company, Person} describe "nested struct encoding" do test "encodes and decodes nested structs correctly" do From 3c22b13d563e854f4bad24176e98ae20ca0bcdbd Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 14:55:12 +0100 Subject: [PATCH 5/8] ci: add OTP 28 and use mix quality.ci - Add OTP 28 to test matrix with exclusions for Elixir < 1.18 - Replace separate format/credo steps with mix quality.ci - Add PLT caching for faster dialyzer runs Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae91634..f0197bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,14 +16,21 @@ jobs: strategy: matrix: elixir: ['1.15', '1.16', '1.17', '1.18', '1.19'] - otp: ['25', '26', '27'] + otp: ['25', '26', '27', '28'] exclude: - # Elixir 1.15 doesn't support OTP 27 + # Elixir 1.15 doesn't support OTP 27+ - elixir: '1.15' otp: '27' - # Elixir 1.16 doesn't support 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' @@ -63,10 +70,6 @@ jobs: quality: name: Code Quality runs-on: ubuntu-latest - strategy: - matrix: - elixir: ['1.19'] - otp: ['27'] steps: - name: Checkout code @@ -75,8 +78,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: ${{matrix.elixir}} - otp-version: ${{matrix.otp}} + elixir-version: '1.19' + otp-version: '27' - name: Restore dependencies cache uses: actions/cache@v4 @@ -84,18 +87,23 @@ jobs: path: | deps _build - key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} + key: ${{ runner.os }}-mix-1.19-27-${{ hashFiles('**/mix.lock') }} restore-keys: | - ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}- + ${{ runner.os }}-mix-1.19-27- + + - name: Restore PLT cache + uses: actions/cache@v4 + with: + path: priv/plts + key: ${{ runner.os }}-plt-1.19-27-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-plt-1.19-27- - 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 From 1c2b79802046448104b350b91f17a0dcdd64a1bf Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 14:59:03 +0100 Subject: [PATCH 6/8] style: restore matrix syntax for consistency Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0197bc..cc4c11f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,25 +15,25 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: ['1.15', '1.16', '1.17', '1.18', '1.19'] - otp: ['25', '26', '27', '28'] + 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.15' - otp: '28' + - 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.16" + otp: "27" + - elixir: "1.16" + otp: "28" # Elixir 1.17 doesn't support OTP 28 - - elixir: '1.17' - 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 @@ -70,6 +70,10 @@ jobs: quality: name: Code Quality runs-on: ubuntu-latest + strategy: + matrix: + elixir: ["1.19"] + otp: ["28"] steps: - name: Checkout code @@ -78,8 +82,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: '1.19' - otp-version: '27' + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} - name: Restore dependencies cache uses: actions/cache@v4 @@ -87,17 +91,17 @@ jobs: path: | deps _build - key: ${{ runner.os }}-mix-1.19-27-${{ hashFiles('**/mix.lock') }} + key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - ${{ runner.os }}-mix-1.19-27- + ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}- - name: Restore PLT cache uses: actions/cache@v4 with: path: priv/plts - key: ${{ runner.os }}-plt-1.19-27-${{ hashFiles('**/mix.lock') }} + key: ${{ runner.os }}-plt-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - ${{ runner.os }}-plt-1.19-27- + ${{ runner.os }}-plt-${{ matrix.elixir }}-${{ matrix.otp }}- - name: Install dependencies run: mix deps.get @@ -110,8 +114,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: ['1.19'] - otp: ['27'] + elixir: ["1.19"] + otp: ["27"] steps: - name: Checkout code From c37aabb68ee403eccc05e1d5490c9bb5aae614fb Mon Sep 17 00:00:00 2001 From: Chris Xu Date: Tue, 20 Jan 2026 15:51:22 +0100 Subject: [PATCH 7/8] fix(dialyzer): resolve all dialyzer warnings - Remove dead code patterns that could never match: - detect_root_type([]) in structural_parser.ex - append_lines(writer, [], _depth) in objects.ex - Define precise types to fix supertype warnings: - Add @type decoded in decode.ex - Add @type validated in options.ex - Use nonempty_list() in arrays.ex and strings.ex specs - Fix protocol fallback no_return warning: - Remove @spec from Toon.Encoder.Any encode/2 functions - Add .dialyzer_ignore.exs for intentional no_return behavior - Add edge case tests to verify behavior before dead code removal Co-Authored-By: Claude Opus 4.5 --- .dialyzer_ignore.exs | 8 +++++ README.md | 1 + lib/toon/decode/decode.ex | 7 +++-- lib/toon/decode/structural_parser.ex | 2 -- lib/toon/encode/arrays.ex | 3 +- lib/toon/encode/objects.ex | 20 ++++-------- lib/toon/encode/options.ex | 11 ++++++- lib/toon/encode/strings.ex | 2 +- lib/toon/encoder.ex | 2 -- mix.exs | 3 +- test/toon/edge_cases_test.exs | 47 ++++++++++++++++++++++++++++ 11 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 .dialyzer_ignore.exs create mode 100644 test/toon/edge_cases_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..e210d92 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -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} +] diff --git a/README.md b/README.md index 5e28423..5169ac9 100644 --- a/README.md +++ b/README.md @@ -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/xu-chris/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. diff --git a/lib/toon/decode/decode.ex b/lib/toon/decode/decode.ex index 812abe4..1d88351 100644 --- a/lib/toon/decode/decode.ex +++ b/lib/toon/decode/decode.ex @@ -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. @@ -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 @@ -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 diff --git a/lib/toon/decode/structural_parser.ex b/lib/toon/decode/structural_parser.ex index 03f75fb..11a8468 100644 --- a/lib/toon/decode/structural_parser.ex +++ b/lib/toon/decode/structural_parser.ex @@ -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 diff --git a/lib/toon/encode/arrays.ex b/lib/toon/encode/arrays.ex index e4b82a6..c8766c7 100644 --- a/lib/toon/encode/arrays.ex +++ b/lib/toon/encode/arrays.ex @@ -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()]] diff --git a/lib/toon/encode/objects.ex b/lib/toon/encode/objects.ex index 1ecd621..e2c7610 100644 --- a/lib/toon/encode/objects.ex +++ b/lib/toon/encode/objects.ex @@ -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) @@ -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 diff --git a/lib/toon/encode/options.ex b/lib/toon/encode/options.ex index 0eccd17..13efad6 100644 --- a/lib/toon/encode/options.ex +++ b/lib/toon/encode/options.ex @@ -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, @@ -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} -> diff --git a/lib/toon/encode/strings.ex b/lib/toon/encode/strings.ex index d7a7133..b3f2aaa 100644 --- a/lib/toon/encode/strings.ex +++ b/lib/toon/encode/strings.ex @@ -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 diff --git a/lib/toon/encoder.ex b/lib/toon/encoder.ex index f6663a3..dbab951 100644 --- a/lib/toon/encoder.ex +++ b/lib/toon/encoder.ex @@ -51,7 +51,6 @@ defimpl Toon.Encoder, for: Any do end end - @spec encode(struct(), keyword()) :: no_return() def encode(%_{} = struct, _opts) do raise Protocol.UndefinedError, protocol: @protocol, @@ -71,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, diff --git a/mix.exs b/mix.exs index 60bb432..94fd15e 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,8 @@ defmodule Toon.MixProject do :underspecs, :unmatched_returns, :unknown - ] + ], + ignore_warnings: ".dialyzer_ignore.exs" ], aliases: aliases() ] diff --git a/test/toon/edge_cases_test.exs b/test/toon/edge_cases_test.exs new file mode 100644 index 0000000..674b226 --- /dev/null +++ b/test/toon/edge_cases_test.exs @@ -0,0 +1,47 @@ +defmodule Toon.EdgeCasesTest do + @moduledoc """ + Tests for edge cases to ensure behavior is preserved after removing dead code. + """ + use ExUnit.Case, async: true + + describe "empty input decoding" do + test "empty string decodes to empty map" do + assert {:ok, %{}} = Toon.decode("") + end + + test "whitespace-only string decodes to empty map" do + assert {:ok, %{}} = Toon.decode(" ") + assert {:ok, %{}} = Toon.decode("\n\n") + assert {:ok, %{}} = Toon.decode(" \n \n ") + end + end + + describe "empty data encoding" do + test "empty map encodes to empty string" do + assert "" = Toon.encode!(%{}) + end + + test "empty list encodes with zero-length header" do + result = Toon.encode!(%{"items" => []}) + assert result =~ "items[0]:" + end + + test "nested empty structures" do + # Empty map inside map - produces key with colon + assert "nested:" = Toon.encode!(%{"nested" => %{}}) + + # Empty list inside map + result = Toon.encode!(%{"items" => []}) + assert result =~ "[0]:" + end + end + + describe "decode and encode roundtrip for edge cases" do + test "empty map roundtrip" do + original = %{} + encoded = Toon.encode!(original) + {:ok, decoded} = Toon.decode(encoded) + assert decoded == original + end + end +end From 4b742b545266a13fabb5b8a37a01459115669285 Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Wed, 28 Jan 2026 16:43:55 +0900 Subject: [PATCH 8/8] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5169ac9..c48e0a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +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/xu-chris/toon_ex/badge.svg?branch=main)](https://coveralls.io/github/xu-chris/toon_ex?branch=main) +[![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.