json_mend 0.3.6 ā 0.3.7
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/.tool-versions +1 -1
- data/benchmark_comparison.rb +22 -4
- data/lib/json_mend/parser.rb +43 -67
- data/lib/json_mend/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 908b32104f5db7f667ee3d7f1273b43b5ae8d7e79193d40953a5d70c0e2c4afd
|
|
4
|
+
data.tar.gz: 8180522b11afd6b43414f67118361b6fc11b83d7afa0b8d8ef189d003a3995c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b70fe8447456214a9fe13d7f2129b3d667687b652c3a7513cfb2385c90b86cffd3e2082a0bb2355610721ec5e688a83ad2ed83a7dd281cd9a9ad034c5c5ca4b1
|
|
7
|
+
data.tar.gz: 88dc8bdc36dedd7869223e5270f3c9e6e7cdfb8359700711ae1e7329355326f220d16a39b919565a5c50f9ab885dc79684a9d0c8dfb110abb329a4a7375dfd4e
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby 4.0.
|
|
1
|
+
ruby 4.0.7
|
data/benchmark_comparison.rb
CHANGED
|
@@ -33,6 +33,16 @@ TEST_CASES = {
|
|
|
33
33
|
label: 'Valid Single JSON',
|
|
34
34
|
input: json_object
|
|
35
35
|
},
|
|
36
|
+
return_objects: {
|
|
37
|
+
label: 'Valid JSON (Return Ruby Objects)',
|
|
38
|
+
input: json_object,
|
|
39
|
+
return_objects: true
|
|
40
|
+
},
|
|
41
|
+
generator_error: {
|
|
42
|
+
label: 'Valid Syntax but Invalid UTF-8 (GeneratorError)',
|
|
43
|
+
input: "{\"status\": \"ok\", \"data\": \"bad\xFFbyte\"}",
|
|
44
|
+
return_objects: true
|
|
45
|
+
},
|
|
36
46
|
concatenated: {
|
|
37
47
|
label: 'Concatenated JSON (x10)',
|
|
38
48
|
input: json_object * 10
|
|
@@ -116,13 +126,18 @@ TEST_CASES.each_value do |data|
|
|
|
116
126
|
puts "\n\nšø Scenario: #{data[:label]}"
|
|
117
127
|
puts '-' * 40
|
|
118
128
|
|
|
129
|
+
# Check if this specific test requires returning objects instead of a string
|
|
130
|
+
return_objs = data.fetch(:return_objects, false)
|
|
131
|
+
|
|
119
132
|
Benchmark.ips do |x|
|
|
120
133
|
x.config(time: 2, warmup: 1) # Short duration for quick checks
|
|
121
134
|
|
|
122
135
|
# 1. JsonMend
|
|
123
|
-
|
|
136
|
+
mend_proc = ->(i) { JsonMend.repair(i, return_objects: return_objs) }
|
|
137
|
+
|
|
138
|
+
if supported?(mend_proc, data[:input])
|
|
124
139
|
x.report('JsonMend') do
|
|
125
|
-
JsonMend.repair(data[:input])
|
|
140
|
+
JsonMend.repair(data[:input], return_objects: return_objs)
|
|
126
141
|
end
|
|
127
142
|
else
|
|
128
143
|
puts ' JsonMend: ā Not Supported'
|
|
@@ -130,9 +145,12 @@ TEST_CASES.each_value do |data|
|
|
|
130
145
|
|
|
131
146
|
# 2. json-repair
|
|
132
147
|
if defined?(JSON::Repair)
|
|
133
|
-
if
|
|
148
|
+
# For a fair comparison, if return_objects is true, json-repair must also parse its own output
|
|
149
|
+
repair_proc = ->(i) { return_objs ? JSON.parse(JSON.repair(i)) : JSON.repair(i) }
|
|
150
|
+
|
|
151
|
+
if supported?(repair_proc, data[:input])
|
|
134
152
|
x.report('json-repair') do
|
|
135
|
-
JSON.repair(data[:input])
|
|
153
|
+
return_objs ? JSON.parse(JSON.repair(data[:input])) : JSON.repair(data[:input])
|
|
136
154
|
end
|
|
137
155
|
else
|
|
138
156
|
puts ' json-repair: ā Not Supported'
|
data/lib/json_mend/parser.rb
CHANGED
|
@@ -7,9 +7,9 @@ module JsonMend
|
|
|
7
7
|
# The core parser that does the heavy lifting of fixing the JSON
|
|
8
8
|
class Parser
|
|
9
9
|
MAX_ALLOWED_DEPTH = 100
|
|
10
|
-
COMMENT_DELIMETERS =
|
|
10
|
+
COMMENT_DELIMETERS = '#/'
|
|
11
11
|
NUMBER_CHARS = Set.new('0123456789-.eE/,_'.chars).freeze
|
|
12
|
-
STRING_DELIMITERS =
|
|
12
|
+
STRING_DELIMITERS = "\"'āā"
|
|
13
13
|
SKIP_CHARS_REGEX_CACHE = {
|
|
14
14
|
'"' => /"/,
|
|
15
15
|
"'" => /'/,
|
|
@@ -29,12 +29,12 @@ module JsonMend
|
|
|
29
29
|
|
|
30
30
|
# Optimized constants for performance (CollectionLiteralInLoop)
|
|
31
31
|
TERMINATORS_ARRAY = [']', '}'].freeze
|
|
32
|
-
TERMINATORS_OBJECT_KEY =
|
|
33
|
-
TERMINATORS_OBJECT_VALUE =
|
|
32
|
+
TERMINATORS_OBJECT_KEY = ':}'
|
|
33
|
+
TERMINATORS_OBJECT_VALUE = ',}'
|
|
34
34
|
TERMINATORS_ARRAY_ITEM = [',', ']'].freeze
|
|
35
|
-
TERMINATORS_STRING_GUESSED =
|
|
36
|
-
TERMINATORS_VALUE =
|
|
37
|
-
STRING_OR_OBJECT_START =
|
|
35
|
+
TERMINATORS_STRING_GUESSED = '{}[],:'
|
|
36
|
+
TERMINATORS_VALUE = ',]}'
|
|
37
|
+
STRING_OR_OBJECT_START = "#{STRING_DELIMITERS}{[".freeze
|
|
38
38
|
SKIPPED_KEYS = %i[merged_array stray_colon].freeze
|
|
39
39
|
BOOLEAN_OR_NULL_CHARS = %w[t f n].freeze
|
|
40
40
|
ESCAPE_START_CHARS = %w[t n r b \\].freeze
|
|
@@ -54,6 +54,7 @@ module JsonMend
|
|
|
54
54
|
def initialize(json_string)
|
|
55
55
|
@scanner = StringScanner.new(json_string)
|
|
56
56
|
@context = []
|
|
57
|
+
@context_counts = Hash.new(0)
|
|
57
58
|
@current_context = nil
|
|
58
59
|
@depth = 0
|
|
59
60
|
end
|
|
@@ -140,12 +141,12 @@ module JsonMend
|
|
|
140
141
|
when '['
|
|
141
142
|
@scanner.getch # consume '['
|
|
142
143
|
return parse_array
|
|
143
|
-
when *COMMENT_DELIMETERS
|
|
144
|
+
when '#', '/' # sync with COMMENT_DELIMETERS, not used *COMMENT_DELIMETERS for branch speed optimization
|
|
144
145
|
# Avoid recursion: consume comment and continue loop
|
|
145
146
|
parse_comment
|
|
146
147
|
else
|
|
147
148
|
if string_start?(char)
|
|
148
|
-
if @context.empty? && !STRING_DELIMITERS.include?(char)
|
|
149
|
+
if @context.empty? && char && !STRING_DELIMITERS.include?(char)
|
|
149
150
|
# Top level unquoted string strictness:
|
|
150
151
|
# Only allow literals (true/false/null), ignore other text as garbage
|
|
151
152
|
val = parse_literal
|
|
@@ -163,7 +164,7 @@ module JsonMend
|
|
|
163
164
|
else
|
|
164
165
|
# Stop if we hit a terminator for the current context to avoid consuming it as garbage
|
|
165
166
|
if (current_context?(:array) && char == ']') ||
|
|
166
|
-
(current_context?(:object_value) && TERMINATORS_OBJECT_VALUE.include?(char)) ||
|
|
167
|
+
(current_context?(:object_value) && char && TERMINATORS_OBJECT_VALUE.include?(char)) ||
|
|
167
168
|
(current_context?(:object_key) && char == '}')
|
|
168
169
|
return JSON_STOP_TOKEN
|
|
169
170
|
end
|
|
@@ -187,7 +188,7 @@ module JsonMend
|
|
|
187
188
|
|
|
188
189
|
# Explicitly consume comments to ensure they don't hide separators (like commas)
|
|
189
190
|
# or get parsed as part of the next key.
|
|
190
|
-
if COMMENT_DELIMETERS.include?(peek_char)
|
|
191
|
+
if peek_char && COMMENT_DELIMETERS.include?(peek_char)
|
|
191
192
|
parse_comment
|
|
192
193
|
next
|
|
193
194
|
end
|
|
@@ -252,17 +253,6 @@ module JsonMend
|
|
|
252
253
|
# If we get an empty key and the next character is a closing brace, we're done.
|
|
253
254
|
return [nil, nil, false] if key.empty? && (peek_char.nil? || peek_char == '}' || @scanner.pos == pos_before_key)
|
|
254
255
|
|
|
255
|
-
# Handle Duplicate Keys (Safer Method)
|
|
256
|
-
# This is a critical repair for lists of objects missing a comma separator.
|
|
257
|
-
if object.key?(key)
|
|
258
|
-
# Instead of rewriting the string, we safely rewind the scanner to the
|
|
259
|
-
# position before the duplicate key. This ends the parsing of the current
|
|
260
|
-
# object, allowing the top-level parser to see the duplicate key as the
|
|
261
|
-
# start of a new JSON object.
|
|
262
|
-
@scanner.pos = pos_before_key
|
|
263
|
-
return [nil, nil, false] # Signal to stop parsing this object.
|
|
264
|
-
end
|
|
265
|
-
|
|
266
256
|
# Parse the Separator (:)
|
|
267
257
|
skip_whitespaces
|
|
268
258
|
colon_found = @scanner.skip(/:/) # Leniently skip the colon if it exists.
|
|
@@ -375,14 +365,14 @@ module JsonMend
|
|
|
375
365
|
char = peek_char
|
|
376
366
|
|
|
377
367
|
# Check for comments explicitly inside array to avoid recursion or garbage consumption issues
|
|
378
|
-
if COMMENT_DELIMETERS.include?(char)
|
|
368
|
+
if char && COMMENT_DELIMETERS.include?(char)
|
|
379
369
|
parse_comment
|
|
380
370
|
char = peek_char
|
|
381
371
|
next
|
|
382
372
|
end
|
|
383
373
|
|
|
384
374
|
value = ''
|
|
385
|
-
if STRING_DELIMITERS.include?(char)
|
|
375
|
+
if char && STRING_DELIMITERS.include?(char)
|
|
386
376
|
# Sometimes it can happen that LLMs forget to start an object and then you think it's a string in an array
|
|
387
377
|
# So we are going to check if this string is followed by a : or not
|
|
388
378
|
# And either parse the string or parse the object
|
|
@@ -424,7 +414,7 @@ module JsonMend
|
|
|
424
414
|
char = peek_char
|
|
425
415
|
|
|
426
416
|
# A valid string can only start with a valid quote or, in our case, with a literal
|
|
427
|
-
while !@scanner.eos? && !STRING_DELIMITERS.include?(char) && !char&.match?(/[\p{L}0-9$_-]/)
|
|
417
|
+
while !@scanner.eos? && char && !STRING_DELIMITERS.include?(char) && !char&.match?(/[\p{L}0-9$_-]/)
|
|
428
418
|
return '' if TERMINATORS_STRING_GUESSED.include?(char)
|
|
429
419
|
|
|
430
420
|
@scanner.getch
|
|
@@ -512,13 +502,13 @@ module JsonMend
|
|
|
512
502
|
doubled_quotes = false
|
|
513
503
|
|
|
514
504
|
# There is sometimes a weird case of doubled quotes, we manage this also later in the while loop
|
|
515
|
-
if STRING_DELIMITERS.include?(peek_char) && peek_char == lstring_delimiter
|
|
505
|
+
if peek_char && STRING_DELIMITERS.include?(peek_char) && peek_char == lstring_delimiter
|
|
516
506
|
next_value = peek_char(1)
|
|
517
507
|
|
|
518
508
|
if (
|
|
519
509
|
current_context?(:object_key) && next_value == ':'
|
|
520
510
|
) || (
|
|
521
|
-
current_context?(:object_value) && TERMINATORS_OBJECT_VALUE.include?(next_value)
|
|
511
|
+
current_context?(:object_value) && next_value && TERMINATORS_OBJECT_VALUE.include?(next_value)
|
|
522
512
|
)
|
|
523
513
|
@scanner.getch
|
|
524
514
|
return [true, '']
|
|
@@ -537,11 +527,13 @@ module JsonMend
|
|
|
537
527
|
# Ok this is not a doubled quote, check if this is an empty string or not
|
|
538
528
|
i = skip_whitespaces_at(start_idx: 1)
|
|
539
529
|
next_c = peek_char(i)
|
|
540
|
-
if
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
530
|
+
if next_c
|
|
531
|
+
if STRING_OR_OBJECT_START.include?(next_c)
|
|
532
|
+
@scanner.getch
|
|
533
|
+
return [true, '']
|
|
534
|
+
elsif !TERMINATORS_VALUE.include?(next_c)
|
|
535
|
+
@scanner.getch
|
|
536
|
+
end
|
|
545
537
|
end
|
|
546
538
|
end
|
|
547
539
|
end
|
|
@@ -582,7 +574,7 @@ module JsonMend
|
|
|
582
574
|
missing_quotes
|
|
583
575
|
)
|
|
584
576
|
|
|
585
|
-
if current_context?(:object_value) && TERMINATORS_OBJECT_VALUE.include?(char) &&
|
|
577
|
+
if current_context?(:object_value) && char && TERMINATORS_OBJECT_VALUE.include?(char) &&
|
|
586
578
|
(string_parts.empty? || string_parts[-1] != rstring_delimiter)
|
|
587
579
|
|
|
588
580
|
is_break = check_rstring_delimiter_missing(
|
|
@@ -712,7 +704,7 @@ module JsonMend
|
|
|
712
704
|
check_comma_in_object_value = false if check_comma_in_object_value && next_c.match?(/\p{L}/)
|
|
713
705
|
|
|
714
706
|
# If we are in an object context, let's check for the right delimiters
|
|
715
|
-
if (context_contain?(:object) && TERMINATORS_OBJECT_KEY.include?(next_c)) ||
|
|
707
|
+
if (context_contain?(:object) && next_c && TERMINATORS_OBJECT_KEY.include?(next_c)) ||
|
|
716
708
|
(context_contain?(:array) && TERMINATORS_ARRAY_ITEM.include?(next_c)) ||
|
|
717
709
|
(
|
|
718
710
|
check_comma_in_object_value &&
|
|
@@ -737,7 +729,7 @@ module JsonMend
|
|
|
737
729
|
i += 1
|
|
738
730
|
i = skip_whitespaces_at(start_idx: i)
|
|
739
731
|
next_c = peek_char(i)
|
|
740
|
-
return [true, false] if TERMINATORS_OBJECT_VALUE.include?(next_c)
|
|
732
|
+
return [true, false] if next_c && TERMINATORS_OBJECT_VALUE.include?(next_c)
|
|
741
733
|
elsif next_c == rstring_delimiter && peek_char(i - 1) != '\\'
|
|
742
734
|
# Check if self.index:self.index+i is only whitespaces
|
|
743
735
|
return [false, false] if skip_whitespaces_at(start_idx: 1) >= i
|
|
@@ -783,7 +775,7 @@ module JsonMend
|
|
|
783
775
|
prev_byte_idx = @scanner.pos - next_c.bytesize - 1
|
|
784
776
|
is_escaped = prev_byte_idx >= 0 && @scanner.string.getbyte(prev_byte_idx) == 92 # 92 is backslash
|
|
785
777
|
|
|
786
|
-
break if TERMINATORS_VALUE.include?(next_c) || (next_c == rstring_delimiter && !is_escaped)
|
|
778
|
+
break if (next_c && TERMINATORS_VALUE.include?(next_c)) || (next_c == rstring_delimiter && !is_escaped)
|
|
787
779
|
|
|
788
780
|
index += 1
|
|
789
781
|
end
|
|
@@ -807,19 +799,7 @@ module JsonMend
|
|
|
807
799
|
|
|
808
800
|
# Scan forward linearly
|
|
809
801
|
while (c = @scanner.getch)
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
# Check if escaped (count preceding backslashes)
|
|
813
|
-
bk = 1
|
|
814
|
-
slashes = 0
|
|
815
|
-
while (@scanner.pos - 1 - bk >= 0) &&
|
|
816
|
-
(char_code = @scanner.string.getbyte(@scanner.pos - 1 - bk)) &&
|
|
817
|
-
char_code == 92 # 92 is backslash
|
|
818
|
-
slashes += 1
|
|
819
|
-
bk += 1
|
|
820
|
-
end
|
|
821
|
-
|
|
822
|
-
if slashes.even?
|
|
802
|
+
if c == rstring_delimiter
|
|
823
803
|
found_next = true
|
|
824
804
|
break
|
|
825
805
|
end
|
|
@@ -846,7 +826,11 @@ module JsonMend
|
|
|
846
826
|
# Jump directly to the exact byte offset after the second quote!
|
|
847
827
|
@scanner.pos = pos_after_second_quote
|
|
848
828
|
@scanner.skip(/\s+/)
|
|
849
|
-
|
|
829
|
+
|
|
830
|
+
# Safely check the next character using the nil guard
|
|
831
|
+
next_char = @scanner.check(/./)
|
|
832
|
+
is_next_closer = next_char && TERMINATORS_VALUE.include?(next_char)
|
|
833
|
+
|
|
850
834
|
@scanner.pos = saved_pos
|
|
851
835
|
end
|
|
852
836
|
|
|
@@ -987,11 +971,7 @@ module JsonMend
|
|
|
987
971
|
string_parts << "\uFFFD"
|
|
988
972
|
else
|
|
989
973
|
# Regular code point or hex escape
|
|
990
|
-
|
|
991
|
-
string_parts << hex_val.chr('UTF-8')
|
|
992
|
-
rescue RangeError
|
|
993
|
-
string_parts << "\uFFFD"
|
|
994
|
-
end
|
|
974
|
+
string_parts << hex_val.chr('UTF-8')
|
|
995
975
|
end
|
|
996
976
|
|
|
997
977
|
# Scanner is already advanced past digits
|
|
@@ -1107,7 +1087,7 @@ module JsonMend
|
|
|
1107
1087
|
end
|
|
1108
1088
|
|
|
1109
1089
|
# Handle cases where the number ends with one or more invalid characters.
|
|
1110
|
-
if !scanned_str.empty? && scanned_str.
|
|
1090
|
+
if !scanned_str.empty? && scanned_str.end_with?(*INVALID_NUMBER_TRAILERS)
|
|
1111
1091
|
# Do not rewind scanner, simply discard the invalid trailing chars (garbage)
|
|
1112
1092
|
scanned_str.sub!(INVALID_NUMBER_TRAILERS_REGEX, '')
|
|
1113
1093
|
end
|
|
@@ -1221,13 +1201,7 @@ module JsonMend
|
|
|
1221
1201
|
# It quickly iterates to find a character, handling escaped characters, and
|
|
1222
1202
|
# returns the index (offset) from the scanner
|
|
1223
1203
|
def skip_to_character(characters, start_idx: 0)
|
|
1224
|
-
pattern = SKIP_CHARS_REGEX_CACHE.fetch(characters
|
|
1225
|
-
# :nocov:
|
|
1226
|
-
if pattern.nil?
|
|
1227
|
-
chars = Array(characters).map { |c| Regexp.escape(c.to_s) }
|
|
1228
|
-
pattern = Regexp.new(chars.join('|'))
|
|
1229
|
-
end
|
|
1230
|
-
# :nocov:
|
|
1204
|
+
pattern = SKIP_CHARS_REGEX_CACHE.fetch(characters)
|
|
1231
1205
|
|
|
1232
1206
|
saved_pos = @scanner.pos
|
|
1233
1207
|
# Skip start_idx
|
|
@@ -1329,11 +1303,13 @@ module JsonMend
|
|
|
1329
1303
|
|
|
1330
1304
|
def push_context(value)
|
|
1331
1305
|
@context.push(value)
|
|
1306
|
+
@context_counts[value] += 1
|
|
1332
1307
|
@current_context = value
|
|
1333
1308
|
end
|
|
1334
1309
|
|
|
1335
1310
|
def pop_context
|
|
1336
|
-
@context.pop
|
|
1311
|
+
popped_value = @context.pop
|
|
1312
|
+
@context_counts[popped_value] -= 1
|
|
1337
1313
|
@current_context = @context.last
|
|
1338
1314
|
end
|
|
1339
1315
|
|
|
@@ -1342,17 +1318,17 @@ module JsonMend
|
|
|
1342
1318
|
end
|
|
1343
1319
|
|
|
1344
1320
|
def context_contain?(value)
|
|
1345
|
-
@
|
|
1321
|
+
@context_counts[value].positive?
|
|
1346
1322
|
end
|
|
1347
1323
|
|
|
1348
1324
|
# Checks if the character signifies the start of a string or literal
|
|
1349
1325
|
def string_start?(char)
|
|
1350
|
-
STRING_DELIMITERS.include?(char) || (char && STRING_START_REGEX.match?(char))
|
|
1326
|
+
(char && STRING_DELIMITERS.include?(char)) || (char && STRING_START_REGEX.match?(char))
|
|
1351
1327
|
end
|
|
1352
1328
|
|
|
1353
1329
|
# Checks if the character signifies the start of a number
|
|
1354
1330
|
def number_start?(char)
|
|
1355
|
-
char
|
|
1331
|
+
(char && char >= '0' && char <= '9') || char == '-' || char == '.'
|
|
1356
1332
|
end
|
|
1357
1333
|
end
|
|
1358
1334
|
end
|
data/lib/json_mend/version.rb
CHANGED
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.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oleksii Vasyliev
|
|
@@ -84,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '0'
|
|
86
86
|
requirements: []
|
|
87
|
-
rubygems_version: 4.0.
|
|
87
|
+
rubygems_version: 4.0.20
|
|
88
88
|
specification_version: 4
|
|
89
89
|
summary: Repair broken JSON
|
|
90
90
|
test_files: []
|