Skip to content

Commit 0d6dfa7

Browse files
author
Chris Xu
committed
Revert "refactor: improve code quality and test coverage (#10)"
This reverts commit c762e61.
1 parent c762e61 commit 0d6dfa7

File tree

16 files changed

+707
-1822
lines changed

16 files changed

+707
-1822
lines changed

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
[![Hex.pm](https://img.shields.io/hexpm/v/toon.svg)](https://hex.pm/packages/toon)
44
[![Documentation](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/toon)
55
[![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)
6-
[![SPEC v3.0](https://img.shields.io/badge/spec-v3.0-fef3c0?labelColor=1b1b1f)](https://github.com/toon-format/spec)
7-
[![License: MIT](https://img.shields.io/badge/license-MIT-fef3c0?labelColor=1b1b1f)](./LICENSE.md)
86

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

119
TOON is a compact data format optimized for LLM token efficiency, achieving **30-60% token reduction** compared to JSON while maintaining readability.
1210

1311
## 🎯 Specification Compliance
1412

15-
This implementation is tested against the [official TOON specification v3.0.1](https://github.com/toon-format/spec) (2025-12-05) using the official test fixtures.
13+
This implementation is tested against the [official TOON specification v1.3.3](https://github.com/toon-format/spec) (2025-10-31) using the official test fixtures.
1614

17-
**Test Fixtures:** [toon-format/spec@f9af6fa](https://github.com/toon-format/spec/tree/v3.0.1)
15+
**Test Fixtures:** [toon-format/spec@b9c71f7](https://github.com/toon-format/spec/tree/b9c71f72f1d243b17a5c21a56273d556a7a08007)
16+
17+
**Compliance Status:**
18+
- ✅ **100% (306/306 tests passing)**
19+
- ✅ **Decoder: 100% (160/160 tests)**
20+
- ✅ **Encoder: 100% (146/146 tests)**
1821

1922
Tests validate semantic equivalence (both outputs decode to the same data structure), ensuring correctness independent of Elixir 1.19's automatic key sorting.
2023

lib/toon/decode/decode.ex

Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ defmodule Toon.Decode do
88
alias Toon.Decode.{Options, StructuralParser}
99
alias Toon.DecodeError
1010

11-
@identifier_segment_pattern ~r/^[A-Za-z_][A-Za-z0-9_]*$/
12-
1311
@typedoc "Decoded TOON value"
1412
@type decoded :: nil | boolean() | binary() | number() | list() | map()
1513

@@ -113,96 +111,11 @@ defmodule Toon.Decode do
113111
defp do_decode(string, opts) do
114112
# Use structural parser for full TOON support
115113
case StructuralParser.parse(string, opts) do
116-
{:ok, {result, metadata}} ->
117-
maybe_expand_paths(result, metadata, opts)
114+
{:ok, result} ->
115+
result
118116

119117
{:error, error} ->
120118
raise error
121119
end
122120
end
123-
124-
# Path expansion per spec v1.5 section 13.4 - entry point with metadata
125-
defp maybe_expand_paths(result, metadata, %{expand_paths: "safe"} = opts) when is_map(result) do
126-
quoted_keys = metadata.quoted_keys
127-
strict = Map.get(opts, :strict, true)
128-
ordered_keys = get_ordered_keys(result, metadata.key_order)
129-
130-
Enum.reduce(ordered_keys, %{}, fn key, acc ->
131-
value = Map.get(result, key) |> maybe_expand_paths_nested(opts)
132-
process_key(acc, key, value, quoted_keys, strict)
133-
end)
134-
end
135-
136-
defp maybe_expand_paths(result, _metadata, opts), do: maybe_expand_paths_nested(result, opts)
137-
138-
# Get keys in document order, falling back to map keys
139-
defp get_ordered_keys(result, []), do: Map.keys(result)
140-
defp get_ordered_keys(result, key_order), do: Enum.filter(key_order, &Map.has_key?(result, &1))
141-
142-
# Process a single key - either expand dotted path or insert directly
143-
defp process_key(acc, key, value, quoted_keys, strict) do
144-
if should_expand?(key, quoted_keys) do
145-
nested = build_nested(String.split(key, "."), value)
146-
deep_merge_with_conflict(acc, nested, strict)
147-
else
148-
insert_key(acc, key, value, strict)
149-
end
150-
end
151-
152-
defp should_expand?(key, quoted_keys) do
153-
expandable_key?(key) and not MapSet.member?(quoted_keys, key)
154-
end
155-
156-
# Insert key with conflict checking
157-
defp insert_key(acc, key, value, _strict) when not is_map_key(acc, key) do
158-
Map.put(acc, key, value)
159-
end
160-
161-
defp insert_key(_acc, key, _value, true = _strict) do
162-
raise DecodeError, message: "Path expansion conflict at key '#{key}'", reason: :path_conflict
163-
end
164-
165-
defp insert_key(acc, key, value, false = _strict) do
166-
Map.put(acc, key, value)
167-
end
168-
169-
# Recursive path expansion for nested structures (no metadata needed)
170-
defp maybe_expand_paths_nested(result, %{expand_paths: "safe"} = opts) when is_list(result) do
171-
Enum.map(result, &maybe_expand_paths_nested(&1, opts))
172-
end
173-
174-
defp maybe_expand_paths_nested(result, _opts), do: result
175-
176-
# IdentifierSegment: [A-Za-z_][A-Za-z0-9_]*
177-
defp expandable_key?(key) do
178-
String.contains?(key, ".") and
179-
key
180-
|> String.split(".")
181-
|> Enum.all?(&Regex.match?(@identifier_segment_pattern, &1))
182-
end
183-
184-
defp build_nested([segment], value), do: %{segment => value}
185-
defp build_nested([segment | rest], value), do: %{segment => build_nested(rest, value)}
186-
187-
defp deep_merge_with_conflict(map1, map2, strict) do
188-
Map.merge(map1, map2, &resolve_merge(&1, &2, &3, strict))
189-
end
190-
191-
defp resolve_merge(_key, v1, v2, strict) when is_map(v1) and is_map(v2) do
192-
deep_merge_with_conflict(v1, v2, strict)
193-
end
194-
195-
defp resolve_merge(key, v1, v2, strict) when is_map(v1) or is_map(v2) do
196-
handle_type_conflict(key, v2, strict)
197-
end
198-
199-
defp resolve_merge(_key, _v1, v2, _strict), do: v2
200-
201-
defp handle_type_conflict(key, _v2, true) do
202-
raise DecodeError,
203-
message: "Path expansion conflict at key '#{key}': incompatible types",
204-
reason: :path_conflict
205-
end
206-
207-
defp handle_type_conflict(_key, v2, false), do: v2
208121
end

lib/toon/decode/options.ex

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ defmodule Toon.Decode.Options do
1818
type: :pos_integer,
1919
default: 2,
2020
doc: "Expected indentation size in spaces (for strict mode validation)"
21-
],
22-
expand_paths: [
23-
type: {:in, ["off", "safe"]},
24-
default: "off",
25-
doc: "Path expansion: 'off' | 'safe' (expand unquoted dotted keys)"
2621
]
2722
]
2823

lib/toon/decode/parser.ex

Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,13 @@ defmodule Toon.Decode.Parser do
3232
unquoted_string =
3333
utf8_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?., ?-], min: 1)
3434

35-
# Quoted key: tag specially to track during path expansion
36-
quoted_key =
37-
quoted_string
38-
|> unwrap_and_tag(:quoted_key)
39-
4035
# Key: quoted or unquoted
4136
key =
4237
choice([
43-
quoted_key,
44-
unquoted_string |> unwrap_and_tag(:key)
38+
quoted_string,
39+
unquoted_string
4540
])
41+
|> unwrap_and_tag(:key)
4642

4743
# Null literal
4844
null_value = string("null") |> replace(nil) |> unwrap_and_tag(:null)
@@ -55,16 +51,11 @@ defmodule Toon.Decode.Parser do
5551
])
5652
|> unwrap_and_tag(:bool)
5753

58-
# Number: integer, float, or exponent notation (e.g., 1e6, -1E+03, 2.5e-2)
54+
# Number: integer or float
5955
number_value =
6056
optional(ascii_string([?-], 1))
6157
|> concat(ascii_string([?0..?9], min: 1))
6258
|> optional(ascii_string([?.], 1) |> concat(ascii_string([?0..?9], min: 1)))
63-
|> optional(
64-
ascii_string([?e, ?E], 1)
65-
|> concat(optional(ascii_string([?+, ?-], 1)))
66-
|> concat(ascii_string([?0..?9], min: 1))
67-
)
6859
|> reduce({Enum, :join, [""]})
6960
|> map({__MODULE__, :parse_number, []})
7061
|> unwrap_and_tag(:number)
@@ -88,37 +79,21 @@ defmodule Toon.Decode.Parser do
8879
string_value
8980
])
9081

91-
# Array length marker: [123] or [123\t] or [123|]
92-
# Per TOON spec v2.0, [#N] format is no longer valid
82+
# Array length marker: [123] or [#123] or [123\t] or [123|]
9383
# Per TOON spec Section 6, non-comma delimiters are indicated in the header
9484
array_length =
9585
ignore(string("["))
86+
|> optional(ignore(string("#")))
9687
|> ascii_string([?0..?9], min: 1)
9788
|> optional(ignore(choice([string("\t"), string("|")])))
9889
|> ignore(string("]"))
9990
|> map({String, :to_integer, []})
10091
|> unwrap_and_tag(:array_length)
10192

102-
# Optional whitespace (for tolerating spaces around delimiters)
103-
optional_ws = ignore(repeat(ascii_char([?\s])))
104-
105-
# Delimiter with optional surrounding whitespace
106-
delimiter_with_ws =
107-
optional_ws
108-
|> concat(ignore(@delimiter))
109-
|> concat(optional_ws)
110-
111-
# Empty value (for handling empty tokens like a,,c)
112-
empty_value = lookahead(choice([@delimiter, eos()])) |> replace("") |> unwrap_and_tag(:string)
113-
114-
# Array value: either a primitive or empty string
115-
array_value = choice([primitive_value, empty_value])
116-
11793
# Inline array values: val1,val2,val3 (or tab/pipe separated)
118-
# Supports spaces around delimiters and empty tokens
11994
inline_array_values =
120-
array_value
121-
|> repeat(delimiter_with_ws |> concat(array_value))
95+
primitive_value
96+
|> repeat(ignore(@delimiter) |> concat(primitive_value))
12297
|> tag(:inline_array)
12398

12499
# Key-value pair: key: value
@@ -159,36 +134,25 @@ defmodule Toon.Decode.Parser do
159134

160135
@doc false
161136
def parse_number(str) when is_binary(str) do
162-
cond do
163-
# Exponent notation (e.g., 1e6, -1E+03, 2.5e-2)
164-
String.contains?(str, "e") or String.contains?(str, "E") ->
165-
# Float.parse handles exponent notation
166-
{float, ""} = Float.parse(str)
167-
# If result is a whole number, return as integer
168-
if float == trunc(float), do: trunc(float), else: float
169-
170-
# Decimal float (e.g., 1.5, -2.0)
171-
String.contains?(str, ".") ->
172-
String.to_float(str)
173-
174-
# Integer
175-
true ->
176-
String.to_integer(str)
137+
if String.contains?(str, ".") do
138+
String.to_float(str)
139+
else
140+
String.to_integer(str)
177141
end
178142
end
179143

180144
@doc false
181-
def make_kv([{_key_type, key}, {_type, value}]) do
145+
def make_kv([{:key, key}, {_type, value}]) do
182146
{key, value}
183147
end
184148

185149
@doc false
186-
def make_empty_array_kv([{_key_type, key}, {:array_length, _len}]) do
150+
def make_empty_array_kv([{:key, key}, {:array_length, _len}]) do
187151
{key, []}
188152
end
189153

190154
@doc false
191-
def make_array_kv([{_key_type, key}, {:array_length, _len}, {:inline_array, values}]) do
155+
def make_array_kv([{:key, key}, {:array_length, _len}, {:inline_array, values}]) do
192156
array_values = Enum.map(values, fn {_type, val} -> val end)
193157
{key, array_values}
194158
end

0 commit comments

Comments
 (0)