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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0db6ff9aaecf22de21a04b136e235fa04d12d43f3c1db614f96eec2c881ce7b9
4
- data.tar.gz: 00aa675a9392fdcd9d528f788144ad7a3064c689e9a6a698132c15ff0a9b4130
3
+ metadata.gz: 908b32104f5db7f667ee3d7f1273b43b5ae8d7e79193d40953a5d70c0e2c4afd
4
+ data.tar.gz: 8180522b11afd6b43414f67118361b6fc11b83d7afa0b8d8ef189d003a3995c2
5
5
  SHA512:
6
- metadata.gz: 22e7132bcfe8f2109be0684afc22bcd21ac8d358dfb0f3b04ad41b14bba5853d82a9d6f1a54d996f8c4ecd9ab0fded3675c147b98fa218513336d7f1856c2a6b
7
- data.tar.gz: f347eb625959326c68c2ffb283ba0d7a170a981927540d16c1b9aa526cde70fb1755cd40e8587af308ed17c83adccddca4a0ea49f986c8536419cfaf99af88bb
6
+ metadata.gz: b70fe8447456214a9fe13d7f2129b3d667687b652c3a7513cfb2385c90b86cffd3e2082a0bb2355610721ec5e688a83ad2ed83a7dd281cd9a9ad034c5c5ca4b1
7
+ data.tar.gz: 88dc8bdc36dedd7869223e5270f3c9e6e7cdfb8359700711ae1e7329355326f220d16a39b919565a5c50f9ab885dc79684a9d0c8dfb110abb329a4a7375dfd4e
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 4.0.6
1
+ ruby 4.0.7
@@ -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
- if supported?(->(i) { JsonMend.repair(i) }, data[:input])
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 supported?(->(i) { JSON.repair(i) }, data[:input])
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'
@@ -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 = ['#', '/'].freeze
10
+ COMMENT_DELIMETERS = '#/'
11
11
  NUMBER_CHARS = Set.new('0123456789-.eE/,_'.chars).freeze
12
- STRING_DELIMITERS = ['"', "'", 'ā€œ', 'ā€'].freeze
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 = [':', '}'].freeze
33
- TERMINATORS_OBJECT_VALUE = [',', '}'].freeze
32
+ TERMINATORS_OBJECT_KEY = ':}'
33
+ TERMINATORS_OBJECT_VALUE = ',}'
34
34
  TERMINATORS_ARRAY_ITEM = [',', ']'].freeze
35
- TERMINATORS_STRING_GUESSED = ['{', '}', '[', ']', ':', ','].freeze
36
- TERMINATORS_VALUE = [',', ']', '}'].freeze
37
- STRING_OR_OBJECT_START = (STRING_DELIMITERS + ['{', '[']).freeze
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 STRING_OR_OBJECT_START.include?(next_c)
541
- @scanner.getch
542
- return [true, '']
543
- elsif !TERMINATORS_VALUE.include?(next_c)
544
- @scanner.getch
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
- next if c != rstring_delimiter
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
- is_next_closer = TERMINATORS_VALUE.include?(@scanner.check(/./))
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
- begin
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.match?(INVALID_NUMBER_TRAILERS_REGEX)
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, nil)
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
- @context.include?(value)
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&.match?(/\d/) || char == '-' || char == '.'
1331
+ (char && char >= '0' && char <= '9') || char == '-' || char == '.'
1356
1332
  end
1357
1333
  end
1358
1334
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsonMend
4
- VERSION = '0.3.6'
4
+ VERSION = '0.3.7'
5
5
  end
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.6
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.16
87
+ rubygems_version: 4.0.20
88
88
  specification_version: 4
89
89
  summary: Repair broken JSON
90
90
  test_files: []