Skip to content

Commit b433b1c

Browse files
committed
Release v0.1.9
1 parent 933b99f commit b433b1c

File tree

5 files changed

+272
-26
lines changed

5 files changed

+272
-26
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.1.9] - 2025-10-28
11+
12+
### Fixed
13+
- **Multi-item arrays**: Layer 2 now closes missing braces and brackets between array elements, avoiding mismatched delimiter swaps during comma handling and restoring proper validation (#8).
14+
15+
### Added
16+
- **Regression coverage**: Added LF and CRLF variants of the reporter's samples to ensure multi-element arrays with missing terminators stay repaired.
17+
1018
## [0.1.8] - 2025-10-27
1119

1220
### Added
@@ -340,7 +348,8 @@ This is a **100% rewrite** - all previous code has been replaced with the new la
340348
- Minimal memory overhead (< 8KB for repairs)
341349
- All operations pass performance thresholds
342350

343-
[Unreleased]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.8...HEAD
351+
[Unreleased]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.9...HEAD
352+
[0.1.9]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.8...v0.1.9
344353
[0.1.8]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.7...v0.1.8
345354
[0.1.7]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.6...v0.1.7
346355
[0.1.6]: https://github.com/nshkrdotcom/json_remedy/compare/v0.1.5...v0.1.6

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Runs **before** the main layer pipeline to handle complex patterns that would ot
7474

7575
### 🏗️ **Structural Repairs (Layer 2)**
7676
- **Missing closing delimiters**: `{"name": "Alice"``{"name": "Alice"}`
77+
- **Array element terminators**: Multi-item arrays recover missing braces/brackets between elements *(v0.1.9)*
7778
- **Extra delimiters**: `{"name": "Alice"}}}``{"name": "Alice"}`
7879
- **Mismatched delimiters**: `[{"name": "Alice"}]` → proper structure
7980
- **Missing opening braces**: `["key": "value"]``[{"key": "value"}]`
@@ -159,7 +160,7 @@ Add JsonRemedy to your `mix.exs`:
159160
```elixir
160161
def deps do
161162
[
162-
{:json_remedy, "~> 0.1.8"}
163+
{:json_remedy, "~> 0.1.9"}
163164
]
164165
end
165166
```

lib/json_remedy/layer2/structural_repair.ex

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ defmodule JsonRemedy.Layer2.StructuralRepair do
137137
"]" ->
138138
handle_closing_bracket(state, char)
139139

140+
"," ->
141+
handle_comma(state, char)
142+
140143
_ ->
141144
# Regular character, just add to result
142145
%{state | result_chars: [char | state.result_chars]}
@@ -288,6 +291,12 @@ defmodule JsonRemedy.Layer2.StructuralRepair do
288291
end
289292
end
290293

294+
@spec handle_comma(state :: state_map(), char :: <<_::8>>) :: state_map()
295+
defp handle_comma(state, char) do
296+
adjusted_state = maybe_close_contexts_before_separator(state)
297+
%{adjusted_state | result_chars: [char | adjusted_state.result_chars]}
298+
end
299+
291300
@spec determine_state_from_stack(stack :: [context_frame()]) :: parser_state()
292301
defp determine_state_from_stack([]), do: :root
293302
defp determine_state_from_stack([%{type: :brace} | _]), do: :object
@@ -344,29 +353,7 @@ defmodule JsonRemedy.Layer2.StructuralRepair do
344353
state.context_stack
345354
|> Enum.reduce({[], []}, fn context_frame, {chars_acc, repairs_acc} ->
346355
{close_char, repair} =
347-
case context_frame.type do
348-
:brace ->
349-
repair = %{
350-
layer: :structural_repair,
351-
action: "added missing closing brace",
352-
position: state.position + length(chars_acc),
353-
original: nil,
354-
replacement: "}"
355-
}
356-
357-
{"}", repair}
358-
359-
:bracket ->
360-
repair = %{
361-
layer: :structural_repair,
362-
action: "added missing closing bracket",
363-
position: state.position + length(chars_acc),
364-
original: nil,
365-
replacement: "]"
366-
}
367-
368-
{"]", repair}
369-
end
356+
closing_info(context_frame.type, state.position + length(chars_acc))
370357

371358
{[close_char | chars_acc], [repair | repairs_acc]}
372359
end)
@@ -380,6 +367,98 @@ defmodule JsonRemedy.Layer2.StructuralRepair do
380367
}
381368
end
382369

370+
@spec maybe_close_contexts_before_separator(state_map()) :: state_map()
371+
defp maybe_close_contexts_before_separator(state) do
372+
next_char = next_significant_char(state)
373+
374+
cond do
375+
next_char in [nil, "\""] ->
376+
state
377+
378+
requires_array_boundary_closure?(state.context_stack) ->
379+
close_contexts_until_array(state)
380+
381+
true ->
382+
state
383+
end
384+
end
385+
386+
@spec next_significant_char(state_map()) :: String.t() | nil
387+
defp next_significant_char(state) do
388+
remaining_length = String.length(state.input) - state.position - 1
389+
390+
if remaining_length <= 0 do
391+
nil
392+
else
393+
state.input
394+
|> String.slice(state.position + 1, remaining_length)
395+
|> String.graphemes()
396+
|> Enum.find(fn char ->
397+
char not in [" ", "\t", "\n", "\r"]
398+
end)
399+
end
400+
end
401+
402+
@spec requires_array_boundary_closure?([context_frame()]) :: boolean()
403+
defp requires_array_boundary_closure?(stack) do
404+
case Enum.find_index(stack, &match?(%{type: :bracket}, &1)) do
405+
nil -> false
406+
0 -> false
407+
_ -> true
408+
end
409+
end
410+
411+
@spec close_contexts_until_array(state_map()) :: state_map()
412+
defp close_contexts_until_array(state) do
413+
{to_close, remaining} =
414+
Enum.split_while(state.context_stack, fn %{type: type} -> type != :bracket end)
415+
416+
if Enum.empty?(to_close) do
417+
state
418+
else
419+
{result_chars, repairs} =
420+
Enum.with_index(to_close)
421+
|> Enum.reduce({state.result_chars, state.repairs}, fn {%{type: type}, idx},
422+
{chars_acc, repairs_acc} ->
423+
{close_char, repair} = closing_info(type, state.position + idx)
424+
{[close_char | chars_acc], [repair | repairs_acc]}
425+
end)
426+
427+
%{
428+
state
429+
| context_stack: remaining,
430+
current_state: determine_state_from_stack(remaining),
431+
result_chars: result_chars,
432+
repairs: repairs
433+
}
434+
end
435+
end
436+
437+
@spec closing_info(delimiter_type(), non_neg_integer()) :: {String.t(), repair_action()}
438+
defp closing_info(:brace, position) do
439+
repair = %{
440+
layer: :structural_repair,
441+
action: "added missing closing brace",
442+
position: position,
443+
original: nil,
444+
replacement: "}"
445+
}
446+
447+
{"}", repair}
448+
end
449+
450+
defp closing_info(:bracket, position) do
451+
repair = %{
452+
layer: :structural_repair,
453+
action: "added missing closing bracket",
454+
position: position,
455+
original: nil,
456+
replacement: "]"
457+
}
458+
459+
{"]", repair}
460+
end
461+
383462
# LayerBehaviour callback implementations
384463

385464
@doc """

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule JsonRemedy.MixProject do
22
use Mix.Project
33

4-
@version "0.1.8"
4+
@version "0.1.9"
55
@source_url "https://github.com/nshkrdotcom/json_remedy"
66

77
def project do
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
defmodule JsonRemedy.Issue8MissingTerminatorsTest do
2+
use ExUnit.Case
3+
4+
@newline_variants ["\n", "\r\n"]
5+
6+
describe "issue #8 regressions for missing terminators in array elements" do
7+
test "repairs missing closing brace on first array element" do
8+
input = """
9+
{
10+
"foo": [
11+
{
12+
"bar": {
13+
"baz": {
14+
}},
15+
{
16+
"foo": {
17+
"bar": 1
18+
}
19+
}
20+
]
21+
}
22+
"""
23+
24+
expected = %{
25+
"foo" => [
26+
%{
27+
"bar" => %{
28+
"baz" => %{}
29+
}
30+
},
31+
%{
32+
"foo" => %{
33+
"bar" => 1
34+
}
35+
}
36+
]
37+
}
38+
39+
Enum.each(@newline_variants, fn newline ->
40+
normalized_input = ensure_line_endings(input, newline)
41+
assert {:ok, ^expected} = JsonRemedy.repair(normalized_input)
42+
end)
43+
end
44+
45+
test "repairs multiple missing closing braces across array elements" do
46+
input = """
47+
{
48+
"foo": [
49+
{
50+
"bar": {
51+
"baz": {
52+
},
53+
{
54+
"foo": {
55+
"bar": 1
56+
}
57+
}
58+
]
59+
}
60+
"""
61+
62+
expected = %{
63+
"foo" => [
64+
%{
65+
"bar" => %{
66+
"baz" => %{}
67+
}
68+
},
69+
%{
70+
"foo" => %{
71+
"bar" => 1
72+
}
73+
}
74+
]
75+
}
76+
77+
Enum.each(@newline_variants, fn newline ->
78+
normalized_input = ensure_line_endings(input, newline)
79+
assert {:ok, ^expected} = JsonRemedy.repair(normalized_input)
80+
end)
81+
end
82+
83+
test "repairs missing closing square bracket in nested array" do
84+
input = """
85+
{
86+
"foo": [
87+
{
88+
"bar": {
89+
"baz": [
90+
},
91+
{
92+
"foo": {
93+
"bar": 1
94+
}
95+
}
96+
]
97+
}
98+
"""
99+
100+
expected = %{
101+
"foo" => [
102+
%{
103+
"bar" => %{
104+
"baz" => []
105+
}
106+
},
107+
%{
108+
"foo" => %{
109+
"bar" => 1
110+
}
111+
}
112+
]
113+
}
114+
115+
Enum.each(@newline_variants, fn newline ->
116+
normalized_input = ensure_line_endings(input, newline)
117+
assert {:ok, ^expected} = JsonRemedy.repair(normalized_input)
118+
end)
119+
end
120+
121+
test "still repairs single-element array baseline" do
122+
input = """
123+
{
124+
"foo": [
125+
{
126+
"bar": {
127+
"baz": [
128+
}
129+
]
130+
}
131+
"""
132+
133+
expected = %{
134+
"foo" => [
135+
%{
136+
"bar" => %{
137+
"baz" => []
138+
}
139+
}
140+
]
141+
}
142+
143+
Enum.each(@newline_variants, fn newline ->
144+
normalized_input = ensure_line_endings(input, newline)
145+
assert {:ok, ^expected} = JsonRemedy.repair(normalized_input)
146+
end)
147+
end
148+
end
149+
150+
defp ensure_line_endings(input, "\n"), do: input
151+
152+
defp ensure_line_endings(input, "\r\n") do
153+
input
154+
|> String.replace("\r\n", "\n")
155+
|> String.replace("\n", "\r\n")
156+
end
157+
end

0 commit comments

Comments
 (0)