json_mend 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -0
- data/.tool-versions +1 -1
- data/lib/json_mend/parser.rb +60 -64
- data/lib/json_mend/version.rb +1 -1
- data/lib/json_mend.rb +7 -6
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0db6ff9aaecf22de21a04b136e235fa04d12d43f3c1db614f96eec2c881ce7b9
|
|
4
|
+
data.tar.gz: 00aa675a9392fdcd9d528f788144ad7a3064c689e9a6a698132c15ff0a9b4130
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22e7132bcfe8f2109be0684afc22bcd21ac8d358dfb0f3b04ad41b14bba5853d82a9d6f1a54d996f8c4ecd9ab0fded3675c147b98fa218513336d7f1856c2a6b
|
|
7
|
+
data.tar.gz: f347eb625959326c68c2ffb283ba0d7a170a981927540d16c1b9aa526cde70fb1755cd40e8587af308ed17c83adccddca4a0ea49f986c8536419cfaf99af88bb
|
data/.rubocop.yml
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
inherit_mode:
|
|
2
|
+
merge:
|
|
3
|
+
- Exclude
|
|
4
|
+
|
|
1
5
|
plugins:
|
|
2
6
|
- rubocop-performance
|
|
3
7
|
- rubocop-rspec
|
|
8
|
+
- rubocop-rake
|
|
4
9
|
|
|
5
10
|
AllCops:
|
|
6
11
|
NewCops: enable
|
|
12
|
+
ParserEngine: parser_prism
|
|
13
|
+
CacheRootDirectory: .rubocop_cache
|
|
14
|
+
MaxFilesInCache: 8000
|
|
15
|
+
UseCache: false
|
|
7
16
|
SuggestExtensions: false
|
|
8
17
|
|
|
9
18
|
Metrics/AbcSize:
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby 4.0.
|
|
1
|
+
ruby 4.0.6
|
data/lib/json_mend/parser.rb
CHANGED
|
@@ -42,12 +42,19 @@ module JsonMend
|
|
|
42
42
|
INVALID_NUMBER_TRAILERS = ['-', 'e', 'E', ','].freeze
|
|
43
43
|
|
|
44
44
|
# Pre-compile regexes for performance
|
|
45
|
+
STRING_START_REGEX = /[\p{L}$_]/
|
|
45
46
|
NUMBER_REGEX = /[#{Regexp.escape(NUMBER_CHARS.to_a.join)}]+/
|
|
46
47
|
NUMBER_NO_COMMA_REGEX = /[#{Regexp.escape(NUMBER_CHARS.dup.tap { |s| s.delete(',') }.to_a.join)}]+/
|
|
48
|
+
INVALID_NUMBER_TRAILERS_REGEX = /[#{Regexp.union(*INVALID_NUMBER_TRAILERS)}]+\z/
|
|
49
|
+
HEX_ESCAPE_REGEXES = {
|
|
50
|
+
'u' => /[0-9a-fA-F]{4}/,
|
|
51
|
+
'x' => /[0-9a-fA-F]{2}/
|
|
52
|
+
}.freeze
|
|
47
53
|
|
|
48
54
|
def initialize(json_string)
|
|
49
55
|
@scanner = StringScanner.new(json_string)
|
|
50
56
|
@context = []
|
|
57
|
+
@current_context = nil
|
|
51
58
|
@depth = 0
|
|
52
59
|
end
|
|
53
60
|
|
|
@@ -72,7 +79,7 @@ module JsonMend
|
|
|
72
79
|
# Ignore strings that look like closing braces garbage (e.g. "}", " ] ")
|
|
73
80
|
next if new_json.is_a?(String) && new_json.match?(/\A\s*[}\]]+\s*\z/)
|
|
74
81
|
|
|
75
|
-
if
|
|
82
|
+
if json.last.is_a?(Hash) && new_json.is_a?(Hash)
|
|
76
83
|
json[-1] = deep_merge_hashes(json.last, new_json)
|
|
77
84
|
else
|
|
78
85
|
json << new_json
|
|
@@ -173,7 +180,7 @@ module JsonMend
|
|
|
173
180
|
with_depth_check do
|
|
174
181
|
object = {}
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
push_context(:object)
|
|
177
184
|
|
|
178
185
|
loop do
|
|
179
186
|
skip_whitespaces
|
|
@@ -217,7 +224,7 @@ module JsonMend
|
|
|
217
224
|
object[key] = value
|
|
218
225
|
end
|
|
219
226
|
|
|
220
|
-
|
|
227
|
+
pop_context
|
|
221
228
|
|
|
222
229
|
object
|
|
223
230
|
end
|
|
@@ -294,7 +301,7 @@ module JsonMend
|
|
|
294
301
|
end
|
|
295
302
|
|
|
296
303
|
# If no merge happened, proceed with standard key parsing.
|
|
297
|
-
|
|
304
|
+
push_context(:object_key)
|
|
298
305
|
is_bracketed = false
|
|
299
306
|
|
|
300
307
|
if char == '['
|
|
@@ -305,7 +312,7 @@ module JsonMend
|
|
|
305
312
|
else
|
|
306
313
|
key = parse_string.to_s
|
|
307
314
|
end
|
|
308
|
-
|
|
315
|
+
pop_context
|
|
309
316
|
|
|
310
317
|
# If the key is empty, consume any stray characters to prevent infinite loops.
|
|
311
318
|
@scanner.getch if key.empty? && !@scanner.check(/[:{\[}\]]/) && !@scanner.eos?
|
|
@@ -315,18 +322,18 @@ module JsonMend
|
|
|
315
322
|
|
|
316
323
|
# Parses the value part of a key-value pair.
|
|
317
324
|
def parse_object_value(colon_found: true)
|
|
318
|
-
|
|
325
|
+
push_context(:object_value)
|
|
319
326
|
skip_whitespaces
|
|
320
327
|
|
|
321
328
|
# Handle cases where the value is missing (e.g. "key": } or "key": ,)
|
|
322
329
|
if @scanner.eos? || @scanner.check(/[,}]/)
|
|
323
|
-
|
|
330
|
+
pop_context
|
|
324
331
|
return colon_found ? '' : :inferred_true
|
|
325
332
|
end
|
|
326
333
|
|
|
327
334
|
# Delegate to the main JSON value parser.
|
|
328
335
|
value = parse_json
|
|
329
|
-
|
|
336
|
+
pop_context
|
|
330
337
|
|
|
331
338
|
# If parse_json returned JSON_STOP_TOKEN (nothing found due to garbage->terminator),
|
|
332
339
|
# treat it as empty string for object values to be safe.
|
|
@@ -337,7 +344,7 @@ module JsonMend
|
|
|
337
344
|
def try_to_merge_dangling_array(object)
|
|
338
345
|
return false unless peek_char == '['
|
|
339
346
|
|
|
340
|
-
prev_key = object.keys
|
|
347
|
+
prev_key = object.keys[-1]
|
|
341
348
|
return false unless prev_key && object[prev_key].is_a?(Array)
|
|
342
349
|
|
|
343
350
|
@scanner.getch # Consume '['
|
|
@@ -360,7 +367,7 @@ module JsonMend
|
|
|
360
367
|
def parse_array
|
|
361
368
|
with_depth_check do
|
|
362
369
|
arr = []
|
|
363
|
-
|
|
370
|
+
push_context(:array)
|
|
364
371
|
char = peek_char
|
|
365
372
|
# Stop when you find the closing bracket or an invalid character like '}'
|
|
366
373
|
while !@scanner.eos? && !TERMINATORS_ARRAY.include?(char)
|
|
@@ -396,18 +403,15 @@ module JsonMend
|
|
|
396
403
|
arr << value
|
|
397
404
|
end
|
|
398
405
|
|
|
406
|
+
@scanner.skip(/[\s,]+/)
|
|
399
407
|
char = peek_char
|
|
400
|
-
while char && char != ']' && (char.match?(/\s/) || char == ',')
|
|
401
|
-
@scanner.getch
|
|
402
|
-
char = peek_char
|
|
403
|
-
end
|
|
404
408
|
end
|
|
405
409
|
|
|
406
410
|
# Handle a potentially missing closing bracket, a common LLM error.
|
|
407
411
|
unless @scanner.scan(']')
|
|
408
412
|
@scanner.scan('}') # Consume } if it was the closer
|
|
409
413
|
end
|
|
410
|
-
|
|
414
|
+
pop_context
|
|
411
415
|
|
|
412
416
|
arr
|
|
413
417
|
end
|
|
@@ -445,7 +449,7 @@ module JsonMend
|
|
|
445
449
|
|
|
446
450
|
doubled_quotes = rest.first
|
|
447
451
|
|
|
448
|
-
string_parts =
|
|
452
|
+
string_parts = +''
|
|
449
453
|
|
|
450
454
|
# Here things get a bit hairy because a string missing the final quote can also be a key or a value in an object
|
|
451
455
|
# In that case we need to use the ":|,|}" characters as terminators of the string
|
|
@@ -574,12 +578,12 @@ module JsonMend
|
|
|
574
578
|
end
|
|
575
579
|
|
|
576
580
|
break if context_termination_reached?(
|
|
577
|
-
char
|
|
578
|
-
missing_quotes
|
|
581
|
+
char,
|
|
582
|
+
missing_quotes
|
|
579
583
|
)
|
|
580
584
|
|
|
581
585
|
if current_context?(:object_value) && TERMINATORS_OBJECT_VALUE.include?(char) &&
|
|
582
|
-
(string_parts.empty? || string_parts
|
|
586
|
+
(string_parts.empty? || string_parts[-1] != rstring_delimiter)
|
|
583
587
|
|
|
584
588
|
is_break = check_rstring_delimiter_missing(
|
|
585
589
|
string_parts:,
|
|
@@ -590,7 +594,7 @@ module JsonMend
|
|
|
590
594
|
break if is_break
|
|
591
595
|
end
|
|
592
596
|
|
|
593
|
-
if char == ']' && context_contain?(:array) && string_parts
|
|
597
|
+
if char == ']' && context_contain?(:array) && string_parts[-1] != rstring_delimiter
|
|
594
598
|
i = skip_to_character(rstring_delimiter)
|
|
595
599
|
# No delimiter found
|
|
596
600
|
break unless peek_char(i)
|
|
@@ -608,7 +612,7 @@ module JsonMend
|
|
|
608
612
|
@scanner.getch # Consume the character
|
|
609
613
|
char = peek_char
|
|
610
614
|
|
|
611
|
-
if !@scanner.eos? && string_parts
|
|
615
|
+
if !@scanner.eos? && string_parts[-1] == '\\'
|
|
612
616
|
# This is a special case, if people use real strings this might happen
|
|
613
617
|
is_next, string_parts, char = parse_escape_sequence(
|
|
614
618
|
string_parts:,
|
|
@@ -627,7 +631,7 @@ module JsonMend
|
|
|
627
631
|
break if is_break
|
|
628
632
|
end
|
|
629
633
|
|
|
630
|
-
if char == rstring_delimiter && string_parts
|
|
634
|
+
if char == rstring_delimiter && string_parts[-1] != '\\'
|
|
631
635
|
if check_doubled_quotes(doubled_quotes, rstring_delimiter)
|
|
632
636
|
# Consumed in helper
|
|
633
637
|
elsif check_missing_quotes_in_object_value(missing_quotes, lstring_delimiter, rstring_delimiter)
|
|
@@ -919,13 +923,7 @@ module JsonMend
|
|
|
919
923
|
rstring_delimiter_missing = false
|
|
920
924
|
elsif peek_char(j)
|
|
921
925
|
# Check for an unmatched opening brace in string_parts
|
|
922
|
-
string_parts.
|
|
923
|
-
next unless c == '{'
|
|
924
|
-
|
|
925
|
-
# Ok then this is part of the string
|
|
926
|
-
rstring_delimiter_missing = false
|
|
927
|
-
break
|
|
928
|
-
end
|
|
926
|
+
rstring_delimiter_missing = false if string_parts.include?('{')
|
|
929
927
|
end
|
|
930
928
|
|
|
931
929
|
end
|
|
@@ -938,18 +936,18 @@ module JsonMend
|
|
|
938
936
|
char:,
|
|
939
937
|
rstring_delimiter:
|
|
940
938
|
)
|
|
941
|
-
if !@scanner.eos? && string_parts
|
|
939
|
+
if !@scanner.eos? && string_parts[-1] == '\\'
|
|
942
940
|
# This is a special case, if people use real strings this might happen
|
|
943
941
|
if char == rstring_delimiter || ESCAPE_START_CHARS.include?(char)
|
|
944
|
-
string_parts.
|
|
942
|
+
string_parts.chop!
|
|
945
943
|
string_parts << ESCAPE_MAPPING.fetch(char, char)
|
|
946
944
|
|
|
947
945
|
@scanner.getch # Consume the character
|
|
948
946
|
char = peek_char
|
|
949
|
-
while !@scanner.eos? && string_parts
|
|
947
|
+
while !@scanner.eos? && string_parts[-1] == '\\' && (char == rstring_delimiter || char == '\\')
|
|
950
948
|
# this is a bit of a special case, if I don't do this it will close the loop or create a train of \\
|
|
951
949
|
# I don't love it though
|
|
952
|
-
string_parts.
|
|
950
|
+
string_parts.chop!
|
|
953
951
|
string_parts << char
|
|
954
952
|
@scanner.getch # Consume the character
|
|
955
953
|
char = peek_char
|
|
@@ -959,21 +957,12 @@ module JsonMend
|
|
|
959
957
|
entry_pos = @scanner.pos
|
|
960
958
|
@scanner.getch # consume 'u' or 'x'
|
|
961
959
|
|
|
962
|
-
|
|
963
|
-
hex_parts = []
|
|
964
|
-
|
|
965
|
-
# Use getch in loop to correctly extract chars (handling multibyte)
|
|
966
|
-
num_chars.times do
|
|
967
|
-
c = @scanner.getch
|
|
968
|
-
break unless c
|
|
969
|
-
|
|
970
|
-
hex_parts << c
|
|
971
|
-
end
|
|
960
|
+
hex_regex = HEX_ESCAPE_REGEXES.fetch(char)
|
|
972
961
|
|
|
973
962
|
# Validate valid hex digits
|
|
974
|
-
if
|
|
975
|
-
string_parts.
|
|
976
|
-
hex_val =
|
|
963
|
+
if (hex_str = @scanner.scan(hex_regex))
|
|
964
|
+
string_parts.chop!
|
|
965
|
+
hex_val = hex_str.to_i(16)
|
|
977
966
|
|
|
978
967
|
if char == 'u' && hex_val.between?(0xD800, 0xDBFF)
|
|
979
968
|
# Handle high surrogate pair
|
|
@@ -1013,7 +1002,7 @@ module JsonMend
|
|
|
1013
1002
|
@scanner.pos = entry_pos
|
|
1014
1003
|
end
|
|
1015
1004
|
elsif STRING_DELIMITERS.include?(char) && char != rstring_delimiter
|
|
1016
|
-
string_parts.
|
|
1005
|
+
string_parts.chop!
|
|
1017
1006
|
string_parts << char
|
|
1018
1007
|
@scanner.getch # Consume the character
|
|
1019
1008
|
char = peek_char
|
|
@@ -1058,19 +1047,19 @@ module JsonMend
|
|
|
1058
1047
|
# we need to update the index only if we had a closing quote
|
|
1059
1048
|
if char == rstring_delimiter
|
|
1060
1049
|
@scanner.getch
|
|
1061
|
-
elsif missing_quotes && current_context?(:object_key) && string_parts
|
|
1062
|
-
string_parts.
|
|
1050
|
+
elsif missing_quotes && current_context?(:object_key) && string_parts[-1] == ','
|
|
1051
|
+
string_parts.chop!
|
|
1063
1052
|
end
|
|
1064
1053
|
|
|
1065
|
-
final_str = string_parts
|
|
1054
|
+
final_str = string_parts
|
|
1066
1055
|
final_str = final_str.rstrip if missing_quotes || final_str.end_with?("\n")
|
|
1067
1056
|
|
|
1068
1057
|
final_str
|
|
1069
1058
|
end
|
|
1070
1059
|
|
|
1071
1060
|
def context_termination_reached?(
|
|
1072
|
-
char
|
|
1073
|
-
missing_quotes
|
|
1061
|
+
char,
|
|
1062
|
+
missing_quotes
|
|
1074
1063
|
)
|
|
1075
1064
|
return false unless missing_quotes
|
|
1076
1065
|
|
|
@@ -1118,9 +1107,9 @@ module JsonMend
|
|
|
1118
1107
|
end
|
|
1119
1108
|
|
|
1120
1109
|
# Handle cases where the number ends with one or more invalid characters.
|
|
1121
|
-
if !scanned_str.empty? &&
|
|
1110
|
+
if !scanned_str.empty? && scanned_str.match?(INVALID_NUMBER_TRAILERS_REGEX)
|
|
1122
1111
|
# Do not rewind scanner, simply discard the invalid trailing chars (garbage)
|
|
1123
|
-
scanned_str
|
|
1112
|
+
scanned_str.sub!(INVALID_NUMBER_TRAILERS_REGEX, '')
|
|
1124
1113
|
end
|
|
1125
1114
|
|
|
1126
1115
|
# Reject non-numbers (e.g., stray periods "." or dashes "-" from LLM conversational text)
|
|
@@ -1306,10 +1295,6 @@ module JsonMend
|
|
|
1306
1295
|
res
|
|
1307
1296
|
end
|
|
1308
1297
|
|
|
1309
|
-
def both_hash?(obj1, obj2)
|
|
1310
|
-
obj1.is_a?(Hash) && obj2.is_a?(Hash)
|
|
1311
|
-
end
|
|
1312
|
-
|
|
1313
1298
|
# Skips whitespaces
|
|
1314
1299
|
def skip_whitespaces
|
|
1315
1300
|
@scanner.skip(/\s+/)
|
|
@@ -1319,12 +1304,13 @@ module JsonMend
|
|
|
1319
1304
|
def peek_char(offset = 0)
|
|
1320
1305
|
# Handle the common 0-offset case
|
|
1321
1306
|
if offset.zero?
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
return nil if
|
|
1307
|
+
pos = @scanner.pos
|
|
1308
|
+
str = @scanner.string
|
|
1309
|
+
return nil if pos >= str.bytesize
|
|
1325
1310
|
|
|
1311
|
+
byte = str.getbyte(pos)
|
|
1326
1312
|
# Fast path: If it's a standard ASCII char (0-127), return it directly.
|
|
1327
|
-
return
|
|
1313
|
+
return byte.chr if byte < 128
|
|
1328
1314
|
|
|
1329
1315
|
# Slow path: If it's a multibyte char (e.g. “), use regex to match the full character.
|
|
1330
1316
|
return @scanner.check(/./m)
|
|
@@ -1341,8 +1327,18 @@ module JsonMend
|
|
|
1341
1327
|
res
|
|
1342
1328
|
end
|
|
1343
1329
|
|
|
1330
|
+
def push_context(value)
|
|
1331
|
+
@context.push(value)
|
|
1332
|
+
@current_context = value
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
def pop_context
|
|
1336
|
+
@context.pop
|
|
1337
|
+
@current_context = @context.last
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1344
1340
|
def current_context?(value)
|
|
1345
|
-
@
|
|
1341
|
+
@current_context == value
|
|
1346
1342
|
end
|
|
1347
1343
|
|
|
1348
1344
|
def context_contain?(value)
|
|
@@ -1351,7 +1347,7 @@ module JsonMend
|
|
|
1351
1347
|
|
|
1352
1348
|
# Checks if the character signifies the start of a string or literal
|
|
1353
1349
|
def string_start?(char)
|
|
1354
|
-
STRING_DELIMITERS.include?(char) || char
|
|
1350
|
+
STRING_DELIMITERS.include?(char) || (char && STRING_START_REGEX.match?(char))
|
|
1355
1351
|
end
|
|
1356
1352
|
|
|
1357
1353
|
# Checks if the character signifies the start of a number
|
data/lib/json_mend/version.rb
CHANGED
data/lib/json_mend.rb
CHANGED
|
@@ -15,21 +15,22 @@ module JsonMend
|
|
|
15
15
|
# @return [Object, String] The repaired JSON object or string.
|
|
16
16
|
def repair(json_string, return_objects: false)
|
|
17
17
|
# First, attempt to parse the string with the standard library.
|
|
18
|
-
|
|
18
|
+
begin
|
|
19
19
|
parsed = JSON.parse(
|
|
20
20
|
json_string,
|
|
21
21
|
allow_trailing_comma: true,
|
|
22
22
|
allow_control_characters: true
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
JSON.generate(parsed)
|
|
25
|
+
# If the user wants objects, return them immediately
|
|
26
|
+
return parsed if return_objects
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
# Otherwise, generate the string once. This acts as both the
|
|
29
|
+
# UTF-8 verification step AND the final string output.
|
|
30
|
+
return JSON.generate(parsed)
|
|
30
31
|
rescue JSON::ParserError, JSON::GeneratorError
|
|
31
32
|
parser = Parser.new(json_string)
|
|
32
|
-
parser.parse
|
|
33
|
+
repaired_json = parser.parse
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# Avoids returning `null` for empty results, returns the object directly
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: json_mend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oleksii Vasyliev
|
|
@@ -77,14 +77,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
77
77
|
requirements:
|
|
78
78
|
- - ">="
|
|
79
79
|
- !ruby/object:Gem::Version
|
|
80
|
-
version: 3.
|
|
80
|
+
version: 3.3.0
|
|
81
81
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
82
|
requirements:
|
|
83
83
|
- - ">="
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '0'
|
|
86
86
|
requirements: []
|
|
87
|
-
rubygems_version: 4.0.
|
|
87
|
+
rubygems_version: 4.0.16
|
|
88
88
|
specification_version: 4
|
|
89
89
|
summary: Repair broken JSON
|
|
90
90
|
test_files: []
|