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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf7c2d2fd3815321d7d80912e4ee200cb94a0399220c17219a02f55305f6eccb
4
- data.tar.gz: 896e1c154c05ce80032072e33d75ddf86335212b0659b9dac93c9c31959da634
3
+ metadata.gz: 0db6ff9aaecf22de21a04b136e235fa04d12d43f3c1db614f96eec2c881ce7b9
4
+ data.tar.gz: 00aa675a9392fdcd9d528f788144ad7a3064c689e9a6a698132c15ff0a9b4130
5
5
  SHA512:
6
- metadata.gz: 54571b75e4f4448f8c3d87ff2ac2a9d569f5a75e602845ccbc3dda7ff00a935dd8cc2263c18e557e2ffc59ded871467867b0b1fd0371198d05fbaa6f7df46453
7
- data.tar.gz: 0f0ef4172ffcbeeb0c28220d6af20603c4fdc7917e833dd2bd111343ec257040767d9a9d21d28d416e8a5096cdfda9baf39a63bf7612ff6cf3b7fb332e8036e2
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.2
1
+ ruby 4.0.6
@@ -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 both_hash?(json.last, new_json)
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
- @context.push(:object)
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
- @context.pop
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
- @context.push(:object_key)
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
- @context.pop
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
- @context.push(:object_value)
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
- @context.pop
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
- @context.pop
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.last
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
- @context.push(:array)
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
- @context.pop
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.last != rstring_delimiter)
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.last != rstring_delimiter
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.last == '\\'
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.last != '\\'
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.reverse_each do |c|
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.last == '\\'
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.pop
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.last == '\\' && (char == rstring_delimiter || char == '\\')
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.pop
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
- num_chars = (char == 'u' ? 4 : 2)
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 hex_parts.length == num_chars && hex_parts.all? { |c| c.match?(/[0-9a-fA-F]/) }
975
- string_parts.pop
976
- hex_val = hex_parts.join.to_i(16)
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.pop
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.last == ','
1062
- string_parts.pop
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.join
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? && INVALID_NUMBER_TRAILERS.include?(scanned_str[-1])
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 = scanned_str[0...-1] while !scanned_str.empty? && INVALID_NUMBER_TRAILERS.include?(scanned_str[-1])
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
- # peek(1) returns the next BYTE, not character
1323
- byte_str = @scanner.peek(1)
1324
- return nil if byte_str.empty?
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 byte_str if byte_str.getbyte(0) < 128
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
- @context&.last == value
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&.match?(/[\p{L}$_]/)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsonMend
4
- VERSION = '0.3.4'
4
+ VERSION = '0.3.6'
5
5
  end
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
- repaired_json = begin
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
- # Verify the native parser didn't produce invalid UTF-8 (like unpaired surrogates)
26
- # by ensuring it can safely dump its own output.
27
- JSON.generate(parsed)
25
+ # If the user wants objects, return them immediately
26
+ return parsed if return_objects
28
27
 
29
- parsed
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
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.2.0
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.6
87
+ rubygems_version: 4.0.16
88
88
  specification_version: 4
89
89
  summary: Repair broken JSON
90
90
  test_files: []