@@ -41,9 +41,13 @@ class JsonRepairer
4141
4242 private int $ stateBeforeString = self ::STATE_START ;
4343
44+ private int $ currentKeyStart = -1 ;
45+
4446 public function __construct (
4547 protected string $ json ,
4648 private readonly bool $ ensureAscii = true ,
49+ private readonly bool $ omitEmptyValues = false ,
50+ private readonly bool $ omitIncompleteStrings = false ,
4751 ) {}
4852
4953 public function repair (): string
@@ -66,6 +70,7 @@ public function repair(): string
6670 $ this ->inString = false ;
6771 $ this ->stringDelimiter = '' ;
6872 $ this ->stateBeforeString = self ::STATE_START ;
73+ $ this ->currentKeyStart = -1 ;
6974
7075 $ length = strlen ($ json );
7176 $ i = 0 ;
@@ -101,6 +106,12 @@ public function repair(): string
101106 $ this ->inString = false ;
102107 $ this ->stringDelimiter = '' ;
103108 $ this ->state = $ this ->getNextStateAfterString ();
109+
110+ // Reset key tracking after successfully completing a string value
111+ if ($ this ->state === self ::STATE_EXPECTING_COMMA_OR_END ) {
112+ $ this ->currentKeyStart = -1 ;
113+ }
114+
104115 $ i ++;
105116 continue ;
106117 }
@@ -139,17 +150,26 @@ public function repair(): string
139150 // Close any unclosed strings
140151 // @phpstan-ignore if.alwaysFalse (can be true if string wasn't closed in loop)
141152 if ($ this ->inString ) {
142- $ this ->output .= '" ' ;
153+ // Check if we should remove incomplete string values
154+ // @phpstan-ignore booleanAnd.alwaysFalse, identical.alwaysFalse (stateBeforeString is set when entering string state and can be STATE_IN_OBJECT_VALUE)
155+ if ($ this ->omitIncompleteStrings && $ this ->stateBeforeString === self ::STATE_IN_OBJECT_VALUE ) {
156+ $ this ->removeCurrentKey ();
157+ // Update state after removing key
158+ $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
159+ } else {
160+ $ this ->output .= '" ' ;
143161
144- // If we were in a string escape state, the escape was incomplete
145- // @phpstan-ignore identical.alwaysFalse (state can be STATE_IN_STRING_ESCAPE if string ended during escape)
146- if ($ this ->state === self ::STATE_IN_STRING_ESCAPE ) {
147- // Remove the incomplete escape backslash
148- $ this ->output = substr ($ this ->output , 0 , -2 ) . substr ($ this ->output , -1 );
162+ // If we were in a string escape state, the escape was incomplete
163+ // @phpstan-ignore identical.alwaysFalse (state can be STATE_IN_STRING_ESCAPE if string ended during escape)
164+ if ($ this ->state === self ::STATE_IN_STRING_ESCAPE ) {
165+ // Remove the incomplete escape backslash
166+ $ this ->output = substr ($ this ->output , 0 , -2 ) . substr ($ this ->output , -1 );
167+ }
168+
169+ // Update state after closing string
170+ $ this ->state = $ this ->getNextStateAfterString ();
149171 }
150172
151- // Update state after closing string
152- $ this ->state = $ this ->getNextStateAfterString ();
153173 $ this ->inString = false ;
154174 }
155175
@@ -158,21 +178,35 @@ public function repair(): string
158178 // @phpstan-ignore identical.alwaysFalse (state set to STATE_EXPECTING_COLON after closing string key)
159179 if ($ this ->state === self ::STATE_EXPECTING_COLON ) {
160180 // We have a key but no colon/value - add colon and empty value
161- $ this ->output .= ':"" ' ;
181+ if ($ this ->omitEmptyValues ) {
182+ $ this ->removeCurrentKey ();
183+ } else {
184+ $ this ->output .= ':"" ' ;
185+ }
186+
162187 $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
163188 // @phpstan-ignore identical.alwaysFalse (state can be STATE_IN_OBJECT_KEY for unquoted keys)
164189 } elseif ($ this ->state === self ::STATE_IN_OBJECT_KEY ) {
165190 // We're still in key state - might have an incomplete unquoted key
166191 // If output ends with a quote, we have a complete key, add colon and empty value
167192 if (str_ends_with ($ this ->output , '" ' ) && ! str_ends_with ($ this ->output , ':" ' )) {
168- $ this ->output .= ':"" ' ;
193+ if ($ this ->omitEmptyValues ) {
194+ $ this ->removeCurrentKey ();
195+ } else {
196+ $ this ->output .= ':"" ' ;
197+ }
169198 }
170199 }
171200
172201 // If we're in OBJECT_VALUE state and output ends with ':', add empty string
173202 // @phpstan-ignore booleanAnd.alwaysFalse, identical.alwaysFalse (state can change during loop)
174203 if ($ this ->state === self ::STATE_IN_OBJECT_VALUE && str_ends_with ($ this ->output , ': ' )) {
175- $ this ->output .= '"" ' ;
204+ if ($ this ->omitEmptyValues ) {
205+ $ this ->removeCurrentKey ();
206+ } else {
207+ $ this ->output .= '"" ' ;
208+ }
209+
176210 $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
177211 }
178212
@@ -184,7 +218,11 @@ public function repair(): string
184218 $ this ->removeTrailingComma ();
185219
186220 if ($ expected === '} ' && str_ends_with ($ this ->output , ': ' )) {
187- $ this ->output .= '"" ' ;
221+ if ($ this ->omitEmptyValues ) {
222+ $ this ->removeCurrentKey ();
223+ } else {
224+ $ this ->output .= '"" ' ;
225+ }
188226 }
189227
190228 $ this ->output .= $ expected ;
@@ -363,6 +401,8 @@ private function handleObjectKey(string $json, int $i): int
363401 }
364402
365403 if ($ char === '" ' || $ char === "' " ) {
404+ // Track where the key starts
405+ $ this ->currentKeyStart = strlen ($ this ->output );
366406 $ this ->output .= '" ' ;
367407 $ this ->inString = true ;
368408 $ this ->stringDelimiter = $ char ;
@@ -374,6 +414,8 @@ private function handleObjectKey(string $json, int $i): int
374414
375415 // Unquoted key
376416 if (ctype_alnum ($ char ) || $ char === '_ ' || $ char === '- ' ) {
417+ // Track where the key starts
418+ $ this ->currentKeyStart = strlen ($ this ->output );
377419 $ this ->output .= '" ' ;
378420 while ($ i < strlen ($ json ) && (ctype_alnum ($ json [$ i ]) || $ json [$ i ] === '_ ' || $ json [$ i ] === '- ' )) {
379421 $ this ->output .= $ json [$ i ];
@@ -419,6 +461,8 @@ private function handleObjectValue(string $json, int $i): int
419461 $ this ->output .= '{ ' ;
420462 $ this ->stack [] = '} ' ;
421463 $ this ->state = self ::STATE_IN_OBJECT_KEY ;
464+ // Reset key tracking when starting a nested object (this is a value, not a key)
465+ $ this ->currentKeyStart = -1 ;
422466
423467 return $ i + 1 ;
424468 }
@@ -427,6 +471,8 @@ private function handleObjectValue(string $json, int $i): int
427471 $ this ->output .= '[ ' ;
428472 $ this ->stack [] = '] ' ;
429473 $ this ->state = self ::STATE_IN_ARRAY ;
474+ // Reset key tracking when starting a nested array (this is a value, not a key)
475+ $ this ->currentKeyStart = -1 ;
430476
431477 return $ i + 1 ;
432478 }
@@ -443,7 +489,11 @@ private function handleObjectValue(string $json, int $i): int
443489
444490 if ($ char === '} ' ) {
445491 if (str_ends_with ($ this ->output , ': ' )) {
446- $ this ->output .= '"" ' ;
492+ if ($ this ->omitEmptyValues ) {
493+ $ this ->removeCurrentKey ();
494+ } else {
495+ $ this ->output .= '"" ' ;
496+ }
447497 }
448498
449499 $ this ->removeTrailingComma ();
@@ -460,6 +510,8 @@ private function handleObjectValue(string $json, int $i): int
460510 if ($ matchResult === 1 ) {
461511 $ this ->output .= $ this ->normalizeBoolean ($ matches [1 ]);
462512 $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
513+ // Reset key tracking after successfully completing a boolean/null value
514+ $ this ->currentKeyStart = -1 ;
463515
464516 return $ i + strlen ($ matches [1 ]);
465517 }
@@ -473,7 +525,12 @@ private function handleObjectValue(string $json, int $i): int
473525
474526 // Missing value
475527 if ($ char === ', ' || $ char === '} ' ) {
476- $ this ->output .= '"" ' ;
528+ if ($ this ->omitEmptyValues ) {
529+ $ this ->removeCurrentKey ();
530+ } else {
531+ $ this ->output .= '"" ' ;
532+ }
533+
477534 $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
478535
479536 return $ i ;
@@ -626,6 +683,8 @@ private function handleNumber(string $json, int $i): int
626683 }
627684
628685 $ this ->state = self ::STATE_EXPECTING_COMMA_OR_END ;
686+ // Reset key tracking after successfully completing a number value
687+ $ this ->currentKeyStart = -1 ;
629688
630689 return $ i ;
631690 }
@@ -684,4 +743,21 @@ private function normalizeBoolean(string $value): string
684743 default => 'null ' ,
685744 };
686745 }
746+
747+ private function removeCurrentKey (): void
748+ {
749+ if ($ this ->currentKeyStart >= 0 ) {
750+ $ beforeKey = substr ($ this ->output , 0 , $ this ->currentKeyStart );
751+ // Remove preceding comma and whitespace if present
752+ $ beforeKey = rtrim ($ beforeKey );
753+
754+ if (str_ends_with ($ beforeKey , ', ' )) {
755+ $ beforeKey = substr ($ beforeKey , 0 , -1 );
756+ $ beforeKey = rtrim ($ beforeKey );
757+ }
758+
759+ $ this ->output = $ beforeKey ;
760+ $ this ->currentKeyStart = -1 ;
761+ }
762+ }
687763}
0 commit comments