From ba248b9b29b2f059d19dd017bac55a2ab6598bff Mon Sep 17 00:00:00 2001 From: uvlad7 Date: Mon, 31 Aug 2026 00:13:29 +0300 Subject: [PATCH 1/5] Handle duplicate keys in parse --- README.md | 21 +++++++-- lib/json_scanner.rb | 93 +++++++++++++++++++++++++++++---------- spec/json_scanner_spec.rb | 28 ++++++++++++ 3 files changed, 116 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1b27380..f464e7e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ end # Result contains byte offsets, you need to be careful when working with non-binary strings emoji_json = '{"grin": "😁", "heart": "😍", "rofl": "🤣"}' -begin_pos, end_pos, = JsonScanner.scan(emoji_json, [["heart"]]).first.first +begin_pos, end_pos, = JsonScanner.scan(emoji_json, [["heart"]]).first.last emoji_json.byteslice(begin_pos...end_pos) # => "\"😍\"" # Note: You most likely don't need the `quirks_mode` option unless you are using @@ -92,7 +92,7 @@ JSON.parse(emoji_json.byteslice(begin_pos...end_pos), quirks_mode: true) # surrogate pairs), while dropping the quotes leaves those escape bytes # untouched. Pass the complete quoted slice to JSON.parse instead. escaped_json = '{"value":"\\"\\\\\\/\\b\\f\\n\\r\\t\\u0061\\uD83D\\uDE01"}' -begin_pos, end_pos, = JsonScanner.scan(escaped_json, [["value"]]).first.first +begin_pos, end_pos, = JsonScanner.scan(escaped_json, [["value"]]).first.last quoted_slice = escaped_json.byteslice(begin_pos...end_pos) quoted_slice.byteslice(1, quoted_slice.bytesize - 2) # => "\\\"\\\\\\/\\b\\f\\n\\r\\t\\u0061\\uD83D\\uDE01" @@ -139,6 +139,21 @@ JsonScanner.parse('[1, 2, null, {"a": 42, "b": 33}, 5]', [[(1..2)], [3, "a"]]) `JsonScanner.parse` supports almost the same options `JsonScanner.scan` does, except `with_path` and `with_roots_info`; it also accepts `JsonScanner::Selector`, but doesn't accept `JsonScanner::Options` +Object keys may be duplicated in JSON. `JsonScanner.scan` returns every matching occurrence, so validate that the result has one element when a duplicate would be invalid, or use `.last` to use the usual JSON/Ruby last-key-wins behavior. `JsonScanner.parse` warns and retains the last value for duplicate keys. Pass `allow_duplicate_key: true` to suppress that warning and forward the option to `JSON.parse` when parsing selected values. + +```ruby +json_str = '{"a": 42, "a": 24}' +matches = JsonScanner.scan(json_str, [["a"]]).first +raise "duplicate key" unless matches.length == 1 + +last_match = matches.last +JSON.parse(json_str.byteslice(last_match[0]...last_match[1]), quirks_mode: true) +# => 24 + +JsonScanner.parse(json_str, [[]], allow_duplicate_key: true) +# => {"a"=>24} +``` + ```ruby JsonScanner.parse('[0, 42, 0]garbage', [[(1..-1)]], allow_trailing_garbage: true) # => [:stub, 42, 0] @@ -212,7 +227,7 @@ JS json_with_trailing_garbage = script_text[/__APOLLO_STATE__\s*=\s*({.+)/, 1] json_end_pos = JsonScanner.scan( json_with_trailing_garbage, [[]], allow_trailing_garbage: true, -).first.first[1] +).first.last[1] apollo_state = JSON.parse(json_with_trailing_garbage[0...json_end_pos]) ``` diff --git a/lib/json_scanner.rb b/lib/json_scanner.rb index cf38c45..14e620a 100644 --- a/lib/json_scanner.rb +++ b/lib/json_scanner.rb @@ -11,7 +11,8 @@ module JsonScanner class Error < StandardError; end ALLOWED_OPTS = %i[verbose_error allow_comments dont_validate_strings allow_multiple_values - allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names].freeze + allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names + allow_duplicate_key].freeze private_constant :ALLOWED_OPTS STUB = :stub private_constant :STUB @@ -21,61 +22,107 @@ class Error < StandardError; end private_constant :SCAN_OPTIONS def self.parse(json_str, config_or_path_ary, **opts) - # with_path and with_roots_info is set here - unless (extra_opts = opts.keys - ALLOWED_OPTS).empty? - raise ArgumentError, "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" - end - - opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) + validate_parse_options(opts) + parse_options = extract_json_parse_options(opts) results, roots = if opts.empty? scan(json_str, config_or_path_ary, SCAN_OPTIONS) else scan(json_str, config_or_path_ary, **opts, **SCAN_OPTS) end - res = process_results(json_str, results, roots, opts[:symbolize_path_keys]) + res = process_results(json_str, results, roots, parse_options) opts[:allow_multiple_values] ? res : res.first end - def self.process_results(json_str, results, roots, symbolize_names) + def self.validate_parse_options(opts) + # with_path and with_roots_info are set internally. + extra_opts = opts.keys - ALLOWED_OPTS + return if extra_opts.empty? + + message = "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" + raise ArgumentError, message + end + + private_class_method :validate_parse_options + + def self.extract_json_parse_options(opts) + allow_duplicate_key = opts.delete(:allow_duplicate_key) if opts.key?(:allow_duplicate_key) + opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) + options = { symbolize_names: opts[:symbolize_path_keys] } + options[:allow_duplicate_key] = allow_duplicate_key if defined?(allow_duplicate_key) + options + end + + private_class_method :extract_json_parse_options + + def self.process_results(json_str, results, roots, parse_options) # stubs are symbols, so they can be distinguished from real values res = roots.map(&:first) - # results for different path matchers can overlap, in that case we will simply parse more than one time, - # but there shouln't be any surprises in the behavior + parse_context = { json_str: json_str, options: parse_options, values: {} } + # Results for different path matchers can overlap; parse_context caches their shared source slices. results.each do |result| - process_result(res, result, roots, json_str, symbolize_names) + process_result(res, result, roots, parse_context) end res end private_class_method :process_results - def self.process_result(res, result, roots, json_str, symbolize_names) + def self.process_result(res, result, roots, parse_context) current_root_index = 0 next_root = roots[1] + seen_paths = {} result.each do |path, (begin_pos, end_pos, _type)| - while next_root && begin_pos >= next_root[1] - current_root_index += 1 - next_root = roots[current_root_index + 1] - end + new_root_index, next_root = advance_root(current_root_index, next_root, roots, begin_pos) + seen_paths = {} if new_root_index != current_root_index + current_root_index = new_root_index # for 'res[index]' check inside insert_value res[current_root_index] = nil if res[current_root_index].is_a?(Symbol) - insert_value(res, parse_value(json_str, begin_pos, end_pos, symbolize_names), current_root_index, path) + warn_duplicate_path(path, seen_paths, parse_context[:options]) + seen_paths[path.dup] = true + # Paths can be shared by overlapping selectors; insert_value shifts its argument. + insert_value(res, parse_selected_value(parse_context, begin_pos, end_pos), current_root_index, path.dup) end end private_class_method :process_result - def self.parse_value(json_str, begin_pos, end_pos, symbolize_names) - # TODO: opts for JSON.parse - JSON.parse( - json_str.byteslice(begin_pos...end_pos), - quirks_mode: true, symbolize_names: symbolize_names, + def self.advance_root(current_root_index, next_root, roots, begin_pos) + while next_root && begin_pos >= next_root[1] + current_root_index += 1 + next_root = roots[current_root_index + 1] + end + [current_root_index, next_root] + end + + private_class_method :advance_root + + def self.warn_duplicate_path(path, seen_paths, parse_options) + return if parse_options[:allow_duplicate_key] || !seen_paths.key?(path) + + warn "JsonScanner.parse: duplicate key at #{path.inspect}; only the last value is retained" + end + + private_class_method :warn_duplicate_path + + def self.parse_selected_value(parse_context, begin_pos, end_pos) + positions = [begin_pos, end_pos] + parsed_values = parse_context[:values] + return parsed_values[positions] if parsed_values.key?(positions) + + parsed_values[positions] = parse_value( + parse_context[:json_str], begin_pos, end_pos, parse_context[:options], ) end + private_class_method :parse_selected_value + + def self.parse_value(json_str, begin_pos, end_pos, options) + JSON.parse(json_str.byteslice(begin_pos...end_pos), quirks_mode: true, **options) + end + private_class_method :parse_value def self.insert_value(res, parsed_value, index, path) diff --git a/spec/json_scanner_spec.rb b/spec/json_scanner_spec.rb index 34846ac..9eef0bb 100644 --- a/spec/json_scanner_spec.rb +++ b/spec/json_scanner_spec.rb @@ -14,6 +14,9 @@ result = described_class.scan('["1", {"a": 2}]', [[0], [1, "a"], []]) expect(result).to eq([[[1, 4, :string]], [[12, 13, :number]], [[0, 15, :array]]]) expect(described_class.scan('"2"', [[]])).to eq([[[0, 3, :string]]]) + expect(described_class.scan('{"a": 42, "a": 24}', [["a"]])).to eq( + [[[6, 8, :number], [15, 17, :number]]], + ) expect( described_class.scan("[0,1,2,3,4,5,6,7]", [[(0..2)], [(4...6)]]), ).to eq( @@ -469,6 +472,31 @@ it "handles overlapping selectors" do expect(described_class.parse('{"a": 42}', [["a"], [described_class::ANY_KEY]])).to eq({ "a" => 42 }) + + if JSON::VERSION >= "2" + it "warns about duplicate keys unless allowed" do + json_str = '{"a": 42, "b": 0, "a": 24}' + + expect { described_class.parse(json_str, [[described_class::ANY_KEY]]) }.to output(/duplicate key/).to_stderr + + allow(JSON).to receive(:parse).and_call_original + expect do + expect(described_class.parse(json_str, [[described_class::ANY_KEY]], allow_duplicate_key: true)).to eq( + { "a" => 24, "b" => 0 }, + ) + end.not_to output(/duplicate key/).to_stderr + expect(JSON).to have_received(:parse).with("42", hash_including(allow_duplicate_key: true)) + end + + end + + it "parses overlapping selections only once" do + allow(JSON).to receive(:parse).and_call_original + + expect do + expect(described_class.parse('{"a": 42}', [["a"], [described_class::ANY_KEY]])).to eq({ "a" => 42 }) + end.not_to output(/duplicate key/).to_stderr + expect(JSON).to have_received(:parse).with("42", anything).once end end From ab1a80080a513ae56e9f9d5c2248f74f6bbb8079 Mon Sep 17 00:00:00 2001 From: uvlad7 Date: Mon, 31 Aug 2026 00:21:25 +0300 Subject: [PATCH 2/5] Avoid sharing scan results between selectors --- lib/json_scanner.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/json_scanner.rb b/lib/json_scanner.rb index 14e620a..23b1127 100644 --- a/lib/json_scanner.rb +++ b/lib/json_scanner.rb @@ -82,8 +82,7 @@ def self.process_result(res, result, roots, parse_context) res[current_root_index] = nil if res[current_root_index].is_a?(Symbol) warn_duplicate_path(path, seen_paths, parse_context[:options]) seen_paths[path.dup] = true - # Paths can be shared by overlapping selectors; insert_value shifts its argument. - insert_value(res, parse_selected_value(parse_context, begin_pos, end_pos), current_root_index, path.dup) + insert_value(res, parse_selected_value(parse_context, begin_pos, end_pos), current_root_index, path) end end From 9774cbc2481fdae6733e98ec9062333103a4417e Mon Sep 17 00:00:00 2001 From: uvlad7 Date: Mon, 31 Aug 2026 00:24:47 +0300 Subject: [PATCH 3/5] Drop duplicate key parse tracking --- README.md | 4 +- lib/json_scanner.rb | 90 +++++++++------------------------------ spec/json_scanner_spec.rb | 25 +---------- 3 files changed, 25 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index f464e7e..90858cb 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ JsonScanner.parse('[1, 2, null, {"a": 42, "b": 33}, 5]', [[(1..2)], [3, "a"]]) `JsonScanner.parse` supports almost the same options `JsonScanner.scan` does, except `with_path` and `with_roots_info`; it also accepts `JsonScanner::Selector`, but doesn't accept `JsonScanner::Options` -Object keys may be duplicated in JSON. `JsonScanner.scan` returns every matching occurrence, so validate that the result has one element when a duplicate would be invalid, or use `.last` to use the usual JSON/Ruby last-key-wins behavior. `JsonScanner.parse` warns and retains the last value for duplicate keys. Pass `allow_duplicate_key: true` to suppress that warning and forward the option to `JSON.parse` when parsing selected values. +Object keys may be duplicated in JSON. `JsonScanner.scan` returns every matching occurrence, so validate that the result has one element when a duplicate would be invalid, or use `.last` to use the usual JSON/Ruby last-key-wins behavior. `JsonScanner.parse` also retains the last matching value. ```ruby json_str = '{"a": 42, "a": 24}' @@ -150,7 +150,7 @@ last_match = matches.last JSON.parse(json_str.byteslice(last_match[0]...last_match[1]), quirks_mode: true) # => 24 -JsonScanner.parse(json_str, [[]], allow_duplicate_key: true) +JsonScanner.parse(json_str, [["a"]]) # => {"a"=>24} ``` diff --git a/lib/json_scanner.rb b/lib/json_scanner.rb index 23b1127..795efc7 100644 --- a/lib/json_scanner.rb +++ b/lib/json_scanner.rb @@ -11,8 +11,7 @@ module JsonScanner class Error < StandardError; end ALLOWED_OPTS = %i[verbose_error allow_comments dont_validate_strings allow_multiple_values - allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names - allow_duplicate_key].freeze + allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names].freeze private_constant :ALLOWED_OPTS STUB = :stub private_constant :STUB @@ -22,106 +21,59 @@ class Error < StandardError; end private_constant :SCAN_OPTIONS def self.parse(json_str, config_or_path_ary, **opts) - validate_parse_options(opts) - parse_options = extract_json_parse_options(opts) + # with_path and with_roots_info are set here + unless (extra_opts = opts.keys - ALLOWED_OPTS).empty? + message = "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" + raise ArgumentError, message + end + + opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) results, roots = if opts.empty? scan(json_str, config_or_path_ary, SCAN_OPTIONS) else scan(json_str, config_or_path_ary, **opts, **SCAN_OPTS) end - res = process_results(json_str, results, roots, parse_options) + res = process_results(json_str, results, roots, opts[:symbolize_path_keys]) opts[:allow_multiple_values] ? res : res.first end - def self.validate_parse_options(opts) - # with_path and with_roots_info are set internally. - extra_opts = opts.keys - ALLOWED_OPTS - return if extra_opts.empty? - - message = "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" - raise ArgumentError, message - end - - private_class_method :validate_parse_options - - def self.extract_json_parse_options(opts) - allow_duplicate_key = opts.delete(:allow_duplicate_key) if opts.key?(:allow_duplicate_key) - opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) - options = { symbolize_names: opts[:symbolize_path_keys] } - options[:allow_duplicate_key] = allow_duplicate_key if defined?(allow_duplicate_key) - options - end - - private_class_method :extract_json_parse_options - - def self.process_results(json_str, results, roots, parse_options) + def self.process_results(json_str, results, roots, symbolize_names) # stubs are symbols, so they can be distinguished from real values res = roots.map(&:first) - parse_context = { json_str: json_str, options: parse_options, values: {} } - # Results for different path matchers can overlap; parse_context caches their shared source slices. results.each do |result| - process_result(res, result, roots, parse_context) + process_result(res, result, roots, json_str, symbolize_names) end res end private_class_method :process_results - def self.process_result(res, result, roots, parse_context) + def self.process_result(res, result, roots, json_str, symbolize_names) current_root_index = 0 next_root = roots[1] - seen_paths = {} result.each do |path, (begin_pos, end_pos, _type)| - new_root_index, next_root = advance_root(current_root_index, next_root, roots, begin_pos) - seen_paths = {} if new_root_index != current_root_index - current_root_index = new_root_index + while next_root && begin_pos >= next_root[1] + current_root_index += 1 + next_root = roots[current_root_index + 1] + end # for 'res[index]' check inside insert_value res[current_root_index] = nil if res[current_root_index].is_a?(Symbol) - warn_duplicate_path(path, seen_paths, parse_context[:options]) - seen_paths[path.dup] = true - insert_value(res, parse_selected_value(parse_context, begin_pos, end_pos), current_root_index, path) + insert_value(res, parse_value(json_str, begin_pos, end_pos, symbolize_names), current_root_index, path) end end private_class_method :process_result - def self.advance_root(current_root_index, next_root, roots, begin_pos) - while next_root && begin_pos >= next_root[1] - current_root_index += 1 - next_root = roots[current_root_index + 1] - end - [current_root_index, next_root] - end - - private_class_method :advance_root - - def self.warn_duplicate_path(path, seen_paths, parse_options) - return if parse_options[:allow_duplicate_key] || !seen_paths.key?(path) - - warn "JsonScanner.parse: duplicate key at #{path.inspect}; only the last value is retained" - end - - private_class_method :warn_duplicate_path - - def self.parse_selected_value(parse_context, begin_pos, end_pos) - positions = [begin_pos, end_pos] - parsed_values = parse_context[:values] - return parsed_values[positions] if parsed_values.key?(positions) - - parsed_values[positions] = parse_value( - parse_context[:json_str], begin_pos, end_pos, parse_context[:options], + def self.parse_value(json_str, begin_pos, end_pos, symbolize_names) + JSON.parse( + json_str.byteslice(begin_pos...end_pos), + quirks_mode: true, symbolize_names: symbolize_names, ) end - private_class_method :parse_selected_value - - def self.parse_value(json_str, begin_pos, end_pos, options) - JSON.parse(json_str.byteslice(begin_pos...end_pos), quirks_mode: true, **options) - end - private_class_method :parse_value def self.insert_value(res, parsed_value, index, path) diff --git a/spec/json_scanner_spec.rb b/spec/json_scanner_spec.rb index 9eef0bb..1d299b1 100644 --- a/spec/json_scanner_spec.rb +++ b/spec/json_scanner_spec.rb @@ -472,31 +472,10 @@ it "handles overlapping selectors" do expect(described_class.parse('{"a": 42}', [["a"], [described_class::ANY_KEY]])).to eq({ "a" => 42 }) - - if JSON::VERSION >= "2" - it "warns about duplicate keys unless allowed" do - json_str = '{"a": 42, "b": 0, "a": 24}' - - expect { described_class.parse(json_str, [[described_class::ANY_KEY]]) }.to output(/duplicate key/).to_stderr - - allow(JSON).to receive(:parse).and_call_original - expect do - expect(described_class.parse(json_str, [[described_class::ANY_KEY]], allow_duplicate_key: true)).to eq( - { "a" => 24, "b" => 0 }, - ) - end.not_to output(/duplicate key/).to_stderr - expect(JSON).to have_received(:parse).with("42", hash_including(allow_duplicate_key: true)) - end - end - it "parses overlapping selections only once" do - allow(JSON).to receive(:parse).and_call_original - - expect do - expect(described_class.parse('{"a": 42}', [["a"], [described_class::ANY_KEY]])).to eq({ "a" => 42 }) - end.not_to output(/duplicate key/).to_stderr - expect(JSON).to have_received(:parse).with("42", anything).once + it "retains the last value for duplicate keys" do + expect(described_class.parse('{"a": 42, "a": 24}', [["a"]])).to eq({ "a" => 24 }) end end From c65d9452567c87e1e2593a184695218a0319f725 Mon Sep 17 00:00:00 2001 From: uvlad7 Date: Mon, 31 Aug 2026 00:53:54 +0300 Subject: [PATCH 4/5] tmp parser injection work --- .rubocop.yml | 3 +++ README.md | 15 ++++++++++++- lib/json_scanner.rb | 47 ++++++++++++++++++++++++--------------- spec/json_scanner_spec.rb | 22 ++++++++++++++++++ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 376ae9f..4d6dce9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -49,5 +49,8 @@ Metrics/AbcSize: Metrics/MethodLength: Max: 15 +Metrics/ParameterLists: + Max: 6 + Bundler/DuplicatedGem: Enabled: false diff --git a/README.md b/README.md index 90858cb..16c1273 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ JsonScanner.parse('[1, 2, null, {"a": 42, "b": 33}, 5]', [[(1..2)], [3, "a"]]) `JsonScanner.parse` supports almost the same options `JsonScanner.scan` does, except `with_path` and `with_roots_info`; it also accepts `JsonScanner::Selector`, but doesn't accept `JsonScanner::Options` -Object keys may be duplicated in JSON. `JsonScanner.scan` returns every matching occurrence, so validate that the result has one element when a duplicate would be invalid, or use `.last` to use the usual JSON/Ruby last-key-wins behavior. `JsonScanner.parse` also retains the last matching value. +Object keys may be duplicated in JSON. `JsonScanner.scan` returns every matching occurrence, so validate that the result has one element when a duplicate would be invalid, or use `.last` to use the usual JSON/Ruby last-key-wins behavior. `JsonScanner.parse` also retains the last matching value. It deliberately does not deduplicate overlapping selectors: duplicate keys have the same path but different source values, so path-based deduplication could retain the first value instead of the last. ```ruby json_str = '{"a": 42, "a": 24}' @@ -154,6 +154,19 @@ JsonScanner.parse(json_str, [["a"]]) # => {"a"=>24} ``` +To customize `JSON.parse`, pass an object that responds to `parse`. For example, this module enables duplicate keys in versions of the `json` gem that support `allow_duplicate_key`: + +```ruby +module AllowDuplicateKeyParser + def self.parse(json_str, **options) + JSON.parse(json_str, **options, allow_duplicate_key: true) + end +end + +JsonScanner.parse(json_str, [[]], parser: AllowDuplicateKeyParser) +# => {"a"=>24} +``` + ```ruby JsonScanner.parse('[0, 42, 0]garbage', [[(1..-1)]], allow_trailing_garbage: true) # => [:stub, 42, 0] diff --git a/lib/json_scanner.rb b/lib/json_scanner.rb index 795efc7..4c1aca3 100644 --- a/lib/json_scanner.rb +++ b/lib/json_scanner.rb @@ -11,7 +11,7 @@ module JsonScanner class Error < StandardError; end ALLOWED_OPTS = %i[verbose_error allow_comments dont_validate_strings allow_multiple_values - allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names].freeze + allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names parser].freeze private_constant :ALLOWED_OPTS STUB = :stub private_constant :STUB @@ -21,36 +21,46 @@ class Error < StandardError; end private_constant :SCAN_OPTIONS def self.parse(json_str, config_or_path_ary, **opts) - # with_path and with_roots_info are set here - unless (extra_opts = opts.keys - ALLOWED_OPTS).empty? - message = "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" - raise ArgumentError, message - end - - opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) + parser, symbolize_names = prepare_parse_options(opts) results, roots = if opts.empty? scan(json_str, config_or_path_ary, SCAN_OPTIONS) else scan(json_str, config_or_path_ary, **opts, **SCAN_OPTS) end - res = process_results(json_str, results, roots, opts[:symbolize_path_keys]) + res = process_results(json_str, results, roots, parser, symbolize_names) opts[:allow_multiple_values] ? res : res.first end - def self.process_results(json_str, results, roots, symbolize_names) - # stubs are symbols, so they can be distinguished from real values - res = roots.map(&:first) + def self.prepare_parse_options(opts) + # with_path and with_roots_info are set here + unless (extra_opts = opts.keys - ALLOWED_OPTS).empty? + message = "unknown keyword#{"s" if extra_opts.size > 1}: #{extra_opts.map(&:inspect).join(", ")}" + raise ArgumentError, message + end + + parser = opts.delete(:parser) || JSON + opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) + [parser, opts[:symbolize_path_keys]] + end + + private_class_method :prepare_parse_options + + def self.process_results(json_str, results, roots, parser, symbolize_names) + res = Array.new(roots.length, STUB) + # Do not deduplicate overlapping matcher results: duplicate JSON keys have the same path but different + # source values, and skipping a result based on its path could retain the first value instead of the last. results.each do |result| - process_result(res, result, roots, json_str, symbolize_names) + process_result(res, result, roots, json_str, parser, symbolize_names) end + res.each_with_index { |value, index| res[index] = roots[index].first if value == STUB } res end private_class_method :process_results - def self.process_result(res, result, roots, json_str, symbolize_names) + def self.process_result(res, result, roots, json_str, parser, symbolize_names) current_root_index = 0 next_root = roots[1] result.each do |path, (begin_pos, end_pos, _type)| @@ -60,15 +70,16 @@ def self.process_result(res, result, roots, json_str, symbolize_names) end # for 'res[index]' check inside insert_value - res[current_root_index] = nil if res[current_root_index].is_a?(Symbol) - insert_value(res, parse_value(json_str, begin_pos, end_pos, symbolize_names), current_root_index, path) + res[current_root_index] = nil if res[current_root_index] == STUB + insert_value(res, parse_value(json_str, begin_pos, end_pos, parser, symbolize_names), current_root_index, path) end end private_class_method :process_result - def self.parse_value(json_str, begin_pos, end_pos, symbolize_names) - JSON.parse( + def self.parse_value(json_str, begin_pos, end_pos, parser, symbolize_names) + # TODO: Forward JSON.parse options directly. + parser.parse( json_str.byteslice(begin_pos...end_pos), quirks_mode: true, symbolize_names: symbolize_names, ) diff --git a/spec/json_scanner_spec.rb b/spec/json_scanner_spec.rb index 1d299b1..f7d64a8 100644 --- a/spec/json_scanner_spec.rb +++ b/spec/json_scanner_spec.rb @@ -477,6 +477,28 @@ it "retains the last value for duplicate keys" do expect(described_class.parse('{"a": 42, "a": 24}', [["a"]])).to eq({ "a" => 24 }) end + + it "allows injecting a parser" do + parser = Module.new do + def self.parse(_json_str, **_options) + :parsed + end + end + + expect(described_class.parse("42", [[]], parser: parser)).to eq(:parsed) + end + + if JSON::VERSION >= "2" + it "allows an injected parser to enable duplicate keys" do + parser = Module.new do + def self.parse(json_str, **options) + JSON.parse(json_str, **options, allow_duplicate_key: true) + end + end + + expect(described_class.parse('{"a": 42, "a": 24}', [[]], parser: parser)).to eq({ "a" => 24 }) + end + end end describe described_class::Selector do From 75b55c987f38a6c9978204e128ac0c5933897f70 Mon Sep 17 00:00:00 2001 From: uvlad7 Date: Thu, 10 Sep 2026 23:33:11 +0300 Subject: [PATCH 5/5] WIP: parse selected values lazily --- README.md | 10 ++-- lib/json_scanner.rb | 97 ++++++++++++++++++++++++++------------- spec/json_scanner_spec.rb | 32 +++++++------ 3 files changed, 85 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 16c1273..66d6360 100644 --- a/README.md +++ b/README.md @@ -154,16 +154,12 @@ JsonScanner.parse(json_str, [["a"]]) # => {"a"=>24} ``` -To customize `JSON.parse`, pass an object that responds to `parse`. For example, this module enables duplicate keys in versions of the `json` gem that support `allow_duplicate_key`: +Pass a block to customize parsing selected values. For example, this enables duplicate keys in versions of the `json` gem that support `allow_duplicate_key`: ```ruby -module AllowDuplicateKeyParser - def self.parse(json_str, **options) - JSON.parse(json_str, **options, allow_duplicate_key: true) - end +JsonScanner.parse(json_str, [[]]) do |value_json, **options| + JSON.parse(value_json, **options, allow_duplicate_key: true) end - -JsonScanner.parse(json_str, [[]], parser: AllowDuplicateKeyParser) # => {"a"=>24} ``` diff --git a/lib/json_scanner.rb b/lib/json_scanner.rb index 4c1aca3..5444a16 100644 --- a/lib/json_scanner.rb +++ b/lib/json_scanner.rb @@ -11,7 +11,7 @@ module JsonScanner class Error < StandardError; end ALLOWED_OPTS = %i[verbose_error allow_comments dont_validate_strings allow_multiple_values - allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names parser].freeze + allow_trailing_garbage allow_partial_values symbolize_path_keys symbolize_names].freeze private_constant :ALLOWED_OPTS STUB = :stub private_constant :STUB @@ -20,15 +20,15 @@ class Error < StandardError; end SCAN_OPTIONS = Options.new(SCAN_OPTS) private_constant :SCAN_OPTIONS - def self.parse(json_str, config_or_path_ary, **opts) - parser, symbolize_names = prepare_parse_options(opts) + def self.parse(json_str, config_or_path_ary, **opts, &parse_block) + symbolize_names = prepare_parse_options(opts) results, roots = if opts.empty? scan(json_str, config_or_path_ary, SCAN_OPTIONS) else scan(json_str, config_or_path_ary, **opts, **SCAN_OPTS) end - res = process_results(json_str, results, roots, parser, symbolize_names) + res = process_results(json_str, results, roots, symbolize_names, &parse_block) opts[:allow_multiple_values] ? res : res.first end @@ -40,64 +40,95 @@ def self.prepare_parse_options(opts) raise ArgumentError, message end - parser = opts.delete(:parser) || JSON opts[:symbolize_path_keys] = opts.delete(:symbolize_names) if opts.key?(:symbolize_names) - [parser, opts[:symbolize_path_keys]] + opts[:symbolize_path_keys] end private_class_method :prepare_parse_options - def self.process_results(json_str, results, roots, parser, symbolize_names) - res = Array.new(roots.length, STUB) - # Do not deduplicate overlapping matcher results: duplicate JSON keys have the same path but different - # source values, and skipping a result based on its path could retain the first value instead of the last. - results.each do |result| - process_result(res, result, roots, json_str, parser, symbolize_names) + def self.process_results(json_str, results, roots, symbolize_names, &parse_block) + # Root type symbols can be distinguished from parsed JSON values. + res = roots.map(&:first) + # Traverse in reverse so the first matching path is the last source occurrence for duplicate JSON keys. + results.reverse_each do |result| + process_result(res, result, roots, json_str, symbolize_names, &parse_block) end - res.each_with_index { |value, index| res[index] = roots[index].first if value == STUB } res end private_class_method :process_results - def self.process_result(res, result, roots, json_str, parser, symbolize_names) - current_root_index = 0 - next_root = roots[1] - result.each do |path, (begin_pos, end_pos, _type)| - while next_root && begin_pos >= next_root[1] - current_root_index += 1 - next_root = roots[current_root_index + 1] - end + def self.process_result(res, result, roots, json_str, symbolize_names, &parse_block) + current_root_index = roots.length - 1 + result.reverse_each do |path, (begin_pos, end_pos, _type)| + current_root_index -= 1 while current_root_index.positive? && begin_pos < roots[current_root_index][1] # for 'res[index]' check inside insert_value - res[current_root_index] = nil if res[current_root_index] == STUB - insert_value(res, parse_value(json_str, begin_pos, end_pos, parser, symbolize_names), current_root_index, path) + root_uninitialized = res[current_root_index].is_a?(Symbol) + res[current_root_index] = nil if root_uninitialized + insert_value(res, current_root_index, path, root_uninitialized) do + parse_value(json_str, begin_pos, end_pos, symbolize_names, &parse_block) + end end end private_class_method :process_result - def self.parse_value(json_str, begin_pos, end_pos, parser, symbolize_names) - # TODO: Forward JSON.parse options directly. - parser.parse( - json_str.byteslice(begin_pos...end_pos), - quirks_mode: true, symbolize_names: symbolize_names, - ) + def self.parse_value(json_str, begin_pos, end_pos, symbolize_names, &parse_block) + value_json = json_str.byteslice(begin_pos...end_pos) + return JSON.parse(value_json, quirks_mode: true, symbolize_names: symbolize_names) unless parse_block + + yield(value_json, quirks_mode: true, symbolize_names: symbolize_names) end private_class_method :parse_value - def self.insert_value(res, parsed_value, index, path) + def self.insert_value(res, index, path, root_uninitialized, &block) + if path.empty? + return assign_value(res, index, &block) if root_uninitialized + + return + end + + res, index = descend_to_value(res, index, path) + return if value_present?(res, index) + + fill_array_index(res, index) + assign_value(res, index, &block) + end + + private_class_method :insert_value + + def self.descend_to_value(res, index, path) until path.empty? new_index = path.shift + fill_array_index(res, index) res[index] ||= new_index.is_a?(Integer) ? [] : {} res = res[index] index = new_index end + [res, index] + end + + private_class_method :descend_to_value - (index - res.size).times { res.push(STUB) } if res.is_a?(Array) && res.size < index - res[index] = parsed_value + def self.value_present?(res, index) + res.is_a?(Hash) ? res.key?(index) : res.size > index && res[index] != STUB end - private_class_method :insert_value + private_class_method :value_present? + + def self.assign_value(res, index) + res[index] = yield + end + + private_class_method :assign_value + + def self.fill_array_index(res, index) + return unless res.is_a?(Array) && res.size < index + + (index - res.size).times { res.push(STUB) } + end + + private_class_method :fill_array_index end diff --git a/spec/json_scanner_spec.rb b/spec/json_scanner_spec.rb index f7d64a8..1180d95 100644 --- a/spec/json_scanner_spec.rb +++ b/spec/json_scanner_spec.rb @@ -478,26 +478,30 @@ expect(described_class.parse('{"a": 42, "a": 24}', [["a"]])).to eq({ "a" => 24 }) end - it "allows injecting a parser" do - parser = Module.new do - def self.parse(_json_str, **_options) - :parsed - end - end - - expect(described_class.parse("42", [[]], parser: parser)).to eq(:parsed) + it "allows custom value parsing" do + expect(described_class.parse("42", [[]]) { :parsed }).to eq(:parsed) end if JSON::VERSION >= "2" - it "allows an injected parser to enable duplicate keys" do - parser = Module.new do - def self.parse(json_str, **options) - JSON.parse(json_str, **options, allow_duplicate_key: true) - end + it "allows custom value parsing to enable duplicate keys" do + value = described_class.parse('{"a": 42, "a": 24}', [[]]) do |value_json, **options| + JSON.parse(value_json, **options, allow_duplicate_key: true) end - expect(described_class.parse('{"a": 42, "a": 24}', [[]], parser: parser)).to eq({ "a" => 24 }) + expect(value).to eq({ "a" => 24 }) + end + end + + it "parses overlapping selectors only once" do + calls = 0 + + value = described_class.parse('{"a": 42}', [["a"], [described_class::ANY_KEY]]) do |value_json, **options| + calls += 1 + JSON.parse(value_json, **options) end + + expect(value).to eq({ "a" => 42 }) + expect(calls).to eq(1) end end