• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

xu-chris / toon_ex / 319baa9c9a8452abc114f3fe00a90cb444803cd8-PR-11

21 Jan 2026 06:55AM UTC coverage: 63.791% (+8.8%) from 54.957%
319baa9c9a8452abc114f3fe00a90cb444803cd8-PR-11

Pull #11

github

Chris Xu
refactor: improve code quality and test coverage

Encoder refactoring:
- Refactor arrays.ex with pattern-matched clauses for composability
- Refactor objects.ex with pattern-matched encode_regular_entry/encode_folded_value
- Rename predicates to follow Elixir conventions (tabular_array?, list_array?)
- Extract reusable helpers (apply_marker, build_*_line functions)

Decoder refactoring:
- Remove Process dictionary anti-pattern from structural_parser.ex
- Thread metadata explicitly through all parsing functions
- Add key_was_quoted? and add_key_to_metadata helpers
- Clean up parser.ex by removing Process.put/get calls

Test improvements:
- Add decode/options_test.exs with 23 tests (100% coverage)
- Add encode/options_test.exs with 25 tests (100% coverage)
- Expand encoder_test.exs from 4 to 23 tests (65% coverage)
- Add test fixtures: UserWithOnly, StructWithoutEncoder
- Strengthen assertions with specific error.value checks

Total: 511 tests, 83.5% coverage, all quality checks pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pull Request #11: Upgrade to TOON Specs v3.0.1 with code quality improvements

248 of 418 new or added lines in 6 files covered. (59.33%)

8 existing lines in 4 files now uncovered.

673 of 1055 relevant lines covered (63.79%)

18.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.42
/lib/toon/encode/objects.ex
1
defmodule Toon.Encode.Objects do
2
  @moduledoc """
3
  Encoding of TOON objects (maps).
4
  """
5

6
  alias Toon.Constants
7
  alias Toon.Encode.{Arrays, Primitives, Strings, Writer}
8
  alias Toon.Utils
9

10
  @identifier_segment_pattern ~r/^[A-Za-z_][A-Za-z0-9_]*$/
11

12
  @doc """
13
  Encodes a map to TOON format.
14

15
  ## Examples
16

17
      iex> opts = %{indent: 2, delimiter: ",", length_marker: nil}
18
      iex> map = %{"name" => "Alice", "age" => 30}
19
      iex> Toon.Encode.Objects.encode(map, 0, opts)
20

21
  """
22
  @spec encode(map(), non_neg_integer(), map()) :: [iodata()]
23
  def encode(map, depth, opts) when is_map(map) do
24
    writer = Writer.new(opts.indent)
60✔
25

26
    # Get the keys in the correct order
27
    keys = get_ordered_keys(map, Map.get(opts, :key_order), [])
60✔
28

29
    # At root level (depth 0), collect dotted keys as forbidden fold paths
30
    opts =
60✔
31
      if depth == 0 and not Map.has_key?(opts, :forbidden_fold_paths) do
60✔
32
        forbidden = collect_forbidden_fold_paths(keys)
34✔
33
        Map.put(opts, :forbidden_fold_paths, forbidden)
34✔
34
      else
35
        opts
26✔
36
      end
37

38
    # Get current path prefix for collision detection
39
    path_prefix = Map.get(opts, :current_path_prefix, "")
60✔
40

41
    writer =
60✔
42
      keys
43
      |> Enum.reduce(writer, fn key, acc ->
44
        value = Map.get(map, key)
78✔
45
        encode_entry(acc, key, value, depth, opts, path_prefix)
78✔
46
      end)
47

48
    Writer.to_iodata(writer)
60✔
49
  end
50

51
  # Collect all dotted keys that should prevent folding
52
  defp collect_forbidden_fold_paths(keys) do
53
    Enum.reduce(keys, MapSet.new(), fn key, acc ->
34✔
54
      if String.contains?(key, "."), do: MapSet.put(acc, key), else: acc
43✔
55
    end)
56
  end
57

58
  # Get keys in the correct order based on key_order option
59
  # Pattern 1: key_order is a map with path-specific ordering
60
  defp get_ordered_keys(map, key_order, path) when is_map(key_order) do
NEW
61
    case Map.fetch(key_order, path) do
×
62
      {:ok, ordered} ->
UNCOV
63
        Enum.filter(ordered, &Map.has_key?(map, &1))
×
64

65
      :error ->
UNCOV
66
        Map.keys(map) |> Enum.sort()
×
67
    end
68
  end
69

70
  # Pattern 2: key_order is a list at root level
71
  defp get_ordered_keys(map, key_order, [])
72
       when is_list(key_order) and key_order != [] do
NEW
73
    existing_keys = Map.keys(map)
×
NEW
74
    ordered_existing = Enum.filter(key_order, &(&1 in existing_keys))
×
75

NEW
76
    if length(ordered_existing) == length(existing_keys) do
×
NEW
77
      ordered_existing
×
78
    else
NEW
79
      Enum.sort(existing_keys)
×
80
    end
81
  end
82

83
  # Pattern 3: No key_order or not applicable - sort alphabetically
84
  defp get_ordered_keys(map, _key_order, _path) do
85
    Map.keys(map) |> Enum.sort()
60✔
86
  end
87

88
  @doc """
89
  Encodes a single key-value pair.
90
  """
91
  @spec encode_entry(Writer.t(), String.t(), term(), non_neg_integer(), map(), String.t()) ::
92
          Writer.t()
93
  def encode_entry(writer, key, value, depth, opts, path_prefix \\ "") do
94
    # Check for key folding
95
    if should_fold?(key, value, opts, path_prefix) do
78✔
96
      encode_folded_entry(writer, key, value, depth, opts)
6✔
97
    else
98
      encode_regular_entry(writer, key, value, depth, opts)
72✔
99
    end
100
  end
101

102
  # Pattern match on value types for better clarity
103
  defp encode_regular_entry(writer, key, value, depth, opts)
104
       when is_nil(value) or is_boolean(value) or is_number(value) or is_binary(value) do
105
    encode_primitive_entry(writer, key, value, depth, opts)
43✔
106
  end
107

108
  defp encode_regular_entry(writer, key, value, depth, opts) when is_list(value) do
109
    array_lines = Arrays.encode(key, value, depth, opts)
8✔
110
    append_lines(writer, array_lines, depth)
8✔
111
  end
112

113
  defp encode_regular_entry(writer, key, value, depth, opts) when is_map(value) do
114
    encode_map_entry(writer, key, value, depth, opts)
21✔
115
  end
116

117
  defp encode_regular_entry(writer, key, _value, depth, opts) do
NEW
118
    encode_null_entry(writer, key, depth, opts)
×
119
  end
120

121
  # Helper functions for each entry type
122
  defp encode_primitive_entry(writer, key, value, depth, opts) do
123
    encoded_key = Strings.encode_key(key)
43✔
124

125
    line = [
43✔
126
      encoded_key,
127
      Constants.colon(),
128
      Constants.space(),
129
      Primitives.encode(value, opts.delimiter)
43✔
130
    ]
131

132
    Writer.push(writer, line, depth)
43✔
133
  end
134

135
  defp encode_map_entry(writer, key, value, depth, opts) do
136
    encoded_key = Strings.encode_key(key)
21✔
137
    header = [encoded_key, Constants.colon()]
21✔
138
    writer = Writer.push(writer, header, depth)
21✔
139

140
    current_prefix = Map.get(opts, :current_path_prefix, "")
21✔
141
    new_prefix = build_path_prefix(current_prefix, key)
21✔
142
    nested_opts = Map.put(opts, :current_path_prefix, new_prefix)
21✔
143
    nested_lines = encode(value, depth + 1, nested_opts)
21✔
144

145
    append_iodata(writer, nested_lines, depth + 1)
21✔
146
  end
147

148
  defp encode_null_entry(writer, key, depth, _opts) do
NEW
149
    encoded_key = Strings.encode_key(key)
×
NEW
150
    line = [encoded_key, Constants.colon(), Constants.space(), Constants.null_literal()]
×
NEW
151
    Writer.push(writer, line, depth)
×
152
  end
153

154
  defp build_path_prefix("", key), do: key
16✔
155
  defp build_path_prefix(prefix, key), do: prefix <> "." <> key
5✔
156

157
  # Check if we should fold this key-value pair into a dotted path
158
  defp should_fold?(key, value, opts, path_prefix) do
159
    case Map.get(opts, :key_folding, "off") do
78✔
160
      "safe" ->
161
        # Only fold single-key maps with valid identifier segments
162
        Utils.map?(value) and
6✔
163
          map_size(value) == 1 and
13✔
164
          valid_identifier_segment?(key) and
13✔
165
          flatten_depth_allows?(opts, 1) and
19✔
166
          not has_collision?(key, value, opts, path_prefix)
8✔
167

168
      _ ->
59✔
169
        false
170
    end
171
  end
172

173
  # Check if folding would create a collision with forbidden fold paths
174
  defp has_collision?(key, value, opts, path_prefix) do
175
    forbidden = Map.get(opts, :forbidden_fold_paths, MapSet.new())
8✔
176

177
    # Compute what the full folded path would be
178
    {path, _final_value} = collect_fold_path([key], value, %{flatten_depth: :infinity}, 1)
8✔
179
    local_folded = Enum.join(path, ".")
8✔
180

181
    # Build the full path from root
182
    full_folded_key =
8✔
183
      if path_prefix == "" do
184
        local_folded
7✔
185
      else
186
        path_prefix <> "." <> local_folded
1✔
187
      end
188

189
    # Check if the full folded path collides with any forbidden path
190
    MapSet.member?(forbidden, full_folded_key)
8✔
191
  end
192

193
  defp valid_identifier_segment?(key) do
194
    Regex.match?(@identifier_segment_pattern, key)
36✔
195
  end
196

197
  defp flatten_depth_allows?(opts, current_depth) do
198
    case Map.get(opts, :flatten_depth, :infinity) do
37✔
199
      :infinity -> true
28✔
200
      max when is_integer(max) -> current_depth <= max
9✔
201
    end
202
  end
203

204
  # Encode a folded key-value pair (collapse single-key chains)
205
  # Pattern match on final value type for clarity
206
  defp encode_folded_entry(writer, key, value, depth, opts) do
207
    {path, final_value} = collect_fold_path([key], value, opts, 1)
6✔
208
    folded_key = Enum.join(path, ".")
6✔
209

210
    encode_folded_value(writer, folded_key, final_value, depth, opts)
6✔
211
  end
212

213
  # Primitive final value
214
  defp encode_folded_value(writer, folded_key, final_value, depth, opts)
215
       when is_nil(final_value) or is_boolean(final_value) or is_number(final_value) or
216
              is_binary(final_value) do
217
    line = [
1✔
218
      folded_key,
219
      Constants.colon(),
220
      Constants.space(),
221
      Primitives.encode(final_value, opts.delimiter)
1✔
222
    ]
223

224
    Writer.push(writer, line, depth)
1✔
225
  end
226

227
  # Array final value
228
  defp encode_folded_value(writer, folded_key, final_value, depth, opts)
229
       when is_list(final_value) do
230
    array_lines = Arrays.encode(folded_key, final_value, depth, opts)
1✔
231
    append_lines(writer, array_lines, depth)
1✔
232
  end
233

234
  # Empty map final value
235
  defp encode_folded_value(writer, folded_key, final_value, depth, _opts)
236
       when is_map(final_value) and map_size(final_value) == 0 do
237
    line = [folded_key, Constants.colon()]
1✔
238
    Writer.push(writer, line, depth)
1✔
239
  end
240

241
  # Non-empty map final value
242
  defp encode_folded_value(writer, folded_key, final_value, depth, opts)
243
       when is_map(final_value) do
244
    nested_opts = Map.put(opts, :flatten_depth, 0)
3✔
245
    header = [folded_key, Constants.colon()]
3✔
246
    writer = Writer.push(writer, header, depth)
3✔
247
    nested_lines = encode(final_value, depth + 1, nested_opts)
3✔
248
    append_iodata(writer, nested_lines, depth + 1)
3✔
249
  end
250

251
  # Unsupported type
252
  defp encode_folded_value(writer, folded_key, _final_value, depth, _opts) do
NEW
253
    line = [folded_key, Constants.colon(), Constants.space(), Constants.null_literal()]
×
NEW
254
    Writer.push(writer, line, depth)
×
255
  end
256

257
  # Recursively collect the path for folding
258
  # Pattern 1: Not a map - stop folding
259
  defp collect_fold_path(path, value, _opts, _current_depth) when not is_map(value) do
8✔
260
    {path, value}
261
  end
262

263
  # Pattern 2: Map with size != 1 - stop folding
264
  defp collect_fold_path(path, value, _opts, _current_depth)
2✔
265
       when is_map(value) and map_size(value) != 1 do
266
    {path, value}
267
  end
268

269
  # Pattern 3: Continue folding if conditions are met
270
  defp collect_fold_path(path, value, opts, current_depth) when is_map(value) do
271
    if flatten_depth_allows?(opts, current_depth + 1) do
25✔
272
      [{next_key, next_value}] = Map.to_list(value)
23✔
273

274
      if valid_identifier_segment?(next_key) do
23✔
275
        collect_fold_path(path ++ [next_key], next_value, opts, current_depth + 1)
21✔
276
      else
277
        {path, value}
278
      end
279
    else
280
      {path, value}
281
    end
282
  end
283

284
  # Private helpers
285

286
  defp append_lines(writer, [header | data_rows], depth) do
287
    # For arrays, the first line is the header at current depth
288
    # Subsequent lines (data rows for tabular format) should be one level deeper
289
    writer = Writer.push(writer, header, depth)
9✔
290

291
    Enum.reduce(data_rows, writer, fn row, acc ->
9✔
292
      Writer.push(acc, row, depth + 1)
8✔
293
    end)
294
  end
295

296
  defp append_iodata(writer, iodata, _base_depth) do
297
    # Convert iodata to string, split by lines, and add to writer
298
    iodata
299
    |> IO.iodata_to_binary()
300
    |> String.split("\n")
301
    |> Enum.reduce(writer, fn line, acc ->
24✔
302
      # Lines from nested encode already have relative indentation,
303
      # but we need to add them without additional depth since encode()
304
      # already handles depth
305
      if line == "" do
41✔
306
        acc
1✔
307
      else
308
        # Extract existing indentation and preserve it
309
        Writer.push(acc, line, 0)
40✔
310
      end
311
    end)
312
  end
313
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc