From c9bd21b5a9af8a568e88e810ae8720723e5db754 Mon Sep 17 00:00:00 2001 From: Ojus Chugh Date: Mon, 17 Aug 2026 01:17:35 +0530 Subject: [PATCH 1/3] Add JSON::ParserError#json_path --- CHANGES.md | 2 + ext/json/ext/parser/parser.c | 85 +++++++++++++++++++++++++++++++---- lib/json/common.rb | 2 +- test/json/json_parser_test.rb | 67 +++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e1cd34a86..5b4bb0199 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ### Unreleased +* Add `JSON::ParserError#json_path` to locate parse errors in the document as a JSONPath-style string (e.g. `$.foo[0].bar`). For duplicate key errors it points at the duplicated key itself. + ### 2026-08-11 (3.0.0.rc1) With the removal of the insecure `create_additions` option, `JSON.load` and `JSON.dump` are diff --git a/ext/json/ext/parser/parser.c b/ext/json/ext/parser/parser.c index f10e2260a..26a766826 100644 --- a/ext/json/ext/parser/parser.c +++ b/ext/json/ext/parser/parser.c @@ -5,7 +5,7 @@ static VALUE mJSON, eNestingError, eParserError, Encoding_UTF_8; static VALUE CNaN, CInfinity, CMinusInfinity, JSON_empty_string; -static ID i_new, i_try_convert, i_encode, i_at_line, i_at_column; +static ID i_new, i_try_convert, i_encode, i_at_line, i_at_column, i_at_json_path; #ifndef HAVE_RB_STR_TO_INTERNED_STR static ID i_uminus; #endif @@ -651,11 +651,74 @@ static VALUE build_parse_error_message(const char *format, JSON_ParserState *sta return rb_enc_sprintf(enc_utf8, format, ptr); } +static void json_path_append_key(VALUE path, VALUE key) +{ + if (RB_SYMBOL_P(key)) { + key = rb_sym2str(key); + } + + if (!RB_TYPE_P(key, T_STRING)) { + rb_str_catf(path, "[%+"PRIsVALUE"]", key); + return; + } + + long len = RSTRING_LEN(key); + bool plain = len > 0; + for (long i = 0; plain && i < len; i++) { + char c = RSTRING_PTR(key)[i]; + bool alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + plain = alpha || (i > 0 && c >= '0' && c <= '9'); + } + + if (plain) { + rb_str_cat_cstr(path, "."); + rb_str_cat(path, RSTRING_PTR(key), len); + return; + } + + rb_str_cat_cstr(path, "[\""); + for (long i = 0; i < RSTRING_LEN(key); i++) { + char c = RSTRING_PTR(key)[i]; + if (c == '"' || c == '\\') { + rb_str_cat(path, "\\", 1); + } + rb_str_cat(path, &c, 1); + } + rb_str_cat_cstr(path, "\"]"); +} + +static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key) +{ + VALUE path = rb_utf8_str_new("$", 1); + json_frame_stack *frames = state->frames; + rvalue_stack *values = state->value_stack; + + for (long depth = 1; depth < frames->head; depth++) { + json_frame *frame = &frames->ptr[depth]; + bool innermost = depth == frames->head - 1; + long child_head = innermost ? values->head : frames->ptr[depth + 1].value_stack_head; + long count = child_head - frame->value_stack_head; + + if (frame->type == JSON_FRAME_ARRAY) { + rb_str_catf(path, "[%ld]", frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count); + } else if (innermost && !UNDEF_P(duplicate_key)) { + json_path_append_key(path, duplicate_key); + } else if (count & 1) { + json_path_append_key(path, values->ptr[child_head - 1]); + } else if (frame->phase == JSON_PHASE_OBJECT_COMMA && count >= 2) { + json_path_append_key(path, values->ptr[child_head - 2]); + } + } + + return path; +} + static VALUE parse_error_new(JSON_ParserState *state, VALUE message, long line, long column, bool eos) { VALUE exc = rb_exc_new_str(eParserError, message); rb_ivar_set(exc, i_at_line, LONG2NUM(line)); rb_ivar_set(exc, i_at_column, LONG2NUM(column)); + rb_ivar_set(exc, i_at_json_path, json_path_new(state, Qundef)); return exc; } @@ -1199,14 +1262,17 @@ NORETURN(static) void raise_duplicate_key_error(JSON_ParserState *state, VALUE d ); rb_str_concat(message, build_parse_error_message("", state)); + VALUE exc; if (state->parser) { // line and columns can't be accurate in resumable - rb_exc_raise(parse_error_new(state, message, 0, 0, false)); + exc = parse_error_new(state, message, 0, 0, false); } else { long line, column; cursor_position(state, &line, &column); rb_str_catf(message, " at line %ld column %ld", line, column); - rb_exc_raise(parse_error_new(state, message, line, column, false)); + exc = parse_error_new(state, message, line, column, false); } + rb_ivar_set(exc, i_at_json_path, json_path_new(state, duplicate_key)); + rb_exc_raise(exc); } NOINLINE(static) void json_on_duplicate_key(JSON_ParserState *state, JSON_ParserConfig *config, size_t count, const VALUE *pairs) @@ -2122,6 +2188,12 @@ static VALUE cParser_parse(JSON_ParserConfig *config, VALUE src) // the rvalue stack. VALUE result = complete ? *rvalue_stack_peek(state->value_stack, 1) : Qundef; + if (complete) { + json_ensure_eof(state, config); + } else { + raise_eos_error("unexpected end of input", state); + } + // This may be skipped in case of exception, but // it won't cause a leak. rvalue_stack_eagerly_release(value_stack_handle); @@ -2130,12 +2202,6 @@ static VALUE cParser_parse(JSON_ParserConfig *config, VALUE src) RB_GC_GUARD(frame_stack_handle); RB_GC_GUARD(Vsource); - if (complete) { - json_ensure_eof(state, config); - } else { - raise_eos_error("unexpected end of input", state); - } - return result; } @@ -2871,6 +2937,7 @@ void Init_parser(void) i_encode = rb_intern("encode"); i_at_line = rb_intern("@line"); i_at_column = rb_intern("@column"); + i_at_json_path = rb_intern("@json_path"); binary_encindex = rb_ascii8bit_encindex(); utf8_encindex = rb_utf8_encindex(); diff --git a/lib/json/common.rb b/lib/json/common.rb index 0512d4aef..7df3edd28 100644 --- a/lib/json/common.rb +++ b/lib/json/common.rb @@ -142,7 +142,7 @@ class JSONError < StandardError; end # This exception is raised if a parser error occurs. class ParserError < JSONError - attr_reader :line, :column + attr_reader :line, :column, :json_path end # This exception is raised if the nesting of parsed data structures is too diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index e7940faa4..f2a6a2951 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -847,6 +847,62 @@ def test_parse_error_snippet assert_equal "unexpected character: '@' at line 1 column 1", error.message end + def test_parse_error_json_path + omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" + + assert_parse_error_at "$", "xyz" + assert_parse_error_at "$.a", '{"a": xyz}' + assert_parse_error_at "$[3]", '[1, 2, "hi", xyz]' + assert_parse_error_at "$.a[1].b", '{"a": [1, {"b": xyz}]}' + assert_parse_error_at "$.a", '{"a": 1 xyz}' + assert_parse_error_at "$", '{"a": 1, xyz}' + assert_parse_error_at "$.a.b.c", '{"a": {"b": {"c":' + assert_parse_error_at "$[5]", '[1,2,3,4,5,' + end + + def test_parse_error_json_path_on_load + assert_parse_error_at "$.a.b.c" do + JSON.load('{"a": {"b": {"c":', -> (obj) { + if String === obj + BasicObject.new + else + obj + end + }) + end + end + + def test_parse_error_json_path_key_escaping + omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" + + assert_parse_error_at '$["hello world"]', '{"hello world": xyz}' + assert_parse_error_at '$["a\"b"]', '{"a\"b": xyz}' + assert_parse_error_at '$[""]', '{"": xyz}' + assert_parse_error_at '$["あ"]', '{"あ": xyz}' + assert_parse_error_at '$.foo["1x"]', '{"foo": {"1x": xyz}}' + end + + def test_parse_error_json_path_duplicate_key + omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" + + assert_parse_error_at "$.a", '{"a": 1, "a": 2}' + assert_parse_error_at "$.x.a", '{"x": {"a": 1, "b": 2, "a": 3}}' + assert_parse_error_at "$.arr[0].a", '{"arr": [{"a": 1, "a": 2}]}' + assert_parse_error_at "$.x.a", '{"x": {"a": 1, "a": 2}}' + end + + def test_parse_error_json_path_resumable + omit "JSON::ResumableParser not available" unless defined?(JSON::ResumableParser) + + parser = JSON::ResumableParser.new + parser << '{"a": [1, {"b": ' + parser.parse + assert_parse_error_at "$.a[1].b" do + parser << 'xyz' + parser.parse + end + end + def test_parse_leading_slash # ref: https://github.com/ruby/ruby/pull/12598 assert_raise(JSON::ParserError) do @@ -888,4 +944,15 @@ def assert_equal_float(expected, actual, delta = 1e-2) Array === actual and actual = actual.first assert_in_delta(expected, actual, delta) end + + def assert_parse_error_at(path, json = nil) + error = assert_raise(JSON::ParserError) do + if block_given? + yield + else + JSON.parse(json) + end + end + assert_equal path, error.json_path + end end From 2b7afaa784b28c9b6b1d41fa543974a47d206131 Mon Sep 17 00:00:00 2001 From: Ojus Chugh Date: Mon, 17 Aug 2026 20:53:13 +0530 Subject: [PATCH 2/3] Truncate json_path at non-string keys --- ext/json/ext/parser/parser.c | 18 ++++++++---------- test/json/json_parser_test.rb | 14 +++++++++++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ext/json/ext/parser/parser.c b/ext/json/ext/parser/parser.c index 26a766826..1d2685187 100644 --- a/ext/json/ext/parser/parser.c +++ b/ext/json/ext/parser/parser.c @@ -651,15 +651,12 @@ static VALUE build_parse_error_message(const char *format, JSON_ParserState *sta return rb_enc_sprintf(enc_utf8, format, ptr); } -static void json_path_append_key(VALUE path, VALUE key) +static bool json_path_append_key(VALUE path, VALUE key) { if (RB_SYMBOL_P(key)) { key = rb_sym2str(key); - } - - if (!RB_TYPE_P(key, T_STRING)) { - rb_str_catf(path, "[%+"PRIsVALUE"]", key); - return; + } else if (!RB_TYPE_P(key, T_STRING)) { + return false; } long len = RSTRING_LEN(key); @@ -673,7 +670,7 @@ static void json_path_append_key(VALUE path, VALUE key) if (plain) { rb_str_cat_cstr(path, "."); rb_str_cat(path, RSTRING_PTR(key), len); - return; + return true; } rb_str_cat_cstr(path, "[\""); @@ -685,6 +682,7 @@ static void json_path_append_key(VALUE path, VALUE key) rb_str_cat(path, &c, 1); } rb_str_cat_cstr(path, "\"]"); + return true; } static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key) @@ -702,11 +700,11 @@ static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key) if (frame->type == JSON_FRAME_ARRAY) { rb_str_catf(path, "[%ld]", frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count); } else if (innermost && !UNDEF_P(duplicate_key)) { - json_path_append_key(path, duplicate_key); + if (!json_path_append_key(path, duplicate_key)) break; } else if (count & 1) { - json_path_append_key(path, values->ptr[child_head - 1]); + if (!json_path_append_key(path, values->ptr[child_head - 1])) break; } else if (frame->phase == JSON_PHASE_OBJECT_COMMA && count >= 2) { - json_path_append_key(path, values->ptr[child_head - 2]); + if (!json_path_append_key(path, values->ptr[child_head - 2])) break; } } diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index f2a6a2951..9568b68fe 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -861,7 +861,9 @@ def test_parse_error_json_path end def test_parse_error_json_path_on_load - assert_parse_error_at "$.a.b.c" do + omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" + + assert_parse_error_at "$" do JSON.load('{"a": {"b": {"c":', -> (obj) { if String === obj BasicObject.new @@ -870,6 +872,16 @@ def test_parse_error_json_path_on_load end }) end + + assert_parse_error_at "$.a" do + JSON.load('{"a": {"b": {"c":', -> (obj) { + if obj == "b" + BasicObject.new + else + obj + end + }) + end end def test_parse_error_json_path_key_escaping From 8550be8635f3d107281b05229f7b5ff250552d20 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 18 Aug 2026 08:50:29 +0200 Subject: [PATCH 3/3] Move most of `ParserError#json_path` building logic in Ruby This isn't a performance sensitive path, and that logic can be shared with the eventual JRuby implementation. --- ext/json/ext/parser/parser.c | 48 +++++++-------------------------- lib/json/common.rb | 51 ++++++++++++++++++++++++++++++++++- test/json/json_parser_test.rb | 6 +++++ 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/ext/json/ext/parser/parser.c b/ext/json/ext/parser/parser.c index 1d2685187..fc6a7bbf6 100644 --- a/ext/json/ext/parser/parser.c +++ b/ext/json/ext/parser/parser.c @@ -651,60 +651,30 @@ static VALUE build_parse_error_message(const char *format, JSON_ParserState *sta return rb_enc_sprintf(enc_utf8, format, ptr); } -static bool json_path_append_key(VALUE path, VALUE key) -{ - if (RB_SYMBOL_P(key)) { - key = rb_sym2str(key); - } else if (!RB_TYPE_P(key, T_STRING)) { - return false; - } - - long len = RSTRING_LEN(key); - bool plain = len > 0; - for (long i = 0; plain && i < len; i++) { - char c = RSTRING_PTR(key)[i]; - bool alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; - plain = alpha || (i > 0 && c >= '0' && c <= '9'); - } - - if (plain) { - rb_str_cat_cstr(path, "."); - rb_str_cat(path, RSTRING_PTR(key), len); - return true; - } - - rb_str_cat_cstr(path, "[\""); - for (long i = 0; i < RSTRING_LEN(key); i++) { - char c = RSTRING_PTR(key)[i]; - if (c == '"' || c == '\\') { - rb_str_cat(path, "\\", 1); - } - rb_str_cat(path, &c, 1); - } - rb_str_cat_cstr(path, "\"]"); - return true; -} - static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key) { - VALUE path = rb_utf8_str_new("$", 1); + VALUE path = rb_ary_new_capa(state->current_nesting); + json_frame_stack *frames = state->frames; rvalue_stack *values = state->value_stack; for (long depth = 1; depth < frames->head; depth++) { json_frame *frame = &frames->ptr[depth]; + bool innermost = depth == frames->head - 1; long child_head = innermost ? values->head : frames->ptr[depth + 1].value_stack_head; long count = child_head - frame->value_stack_head; if (frame->type == JSON_FRAME_ARRAY) { - rb_str_catf(path, "[%ld]", frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count); + rb_ary_push(path, LONG2NUM(frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count)); } else if (innermost && !UNDEF_P(duplicate_key)) { - if (!json_path_append_key(path, duplicate_key)) break; + rb_ary_push(path, duplicate_key); } else if (count & 1) { - if (!json_path_append_key(path, values->ptr[child_head - 1])) break; + rb_ary_push(path, values->ptr[child_head - 1]); } else if (frame->phase == JSON_PHASE_OBJECT_COMMA && count >= 2) { - if (!json_path_append_key(path, values->ptr[child_head - 2])) break; + rb_ary_push(path, values->ptr[child_head - 2]); + } else { + break; } } diff --git a/lib/json/common.rb b/lib/json/common.rb index 7df3edd28..34dc123b5 100644 --- a/lib/json/common.rb +++ b/lib/json/common.rb @@ -142,7 +142,56 @@ class JSONError < StandardError; end # This exception is raised if a parser error occurs. class ParserError < JSONError - attr_reader :line, :column, :json_path + # Line number where the parser encountered an error. + # Is nil when raised by JSON::ResumableParser. + attr_reader :line + + # Column number where the parser encountered an error. + # Is nil when raised by JSON::ResumableParser. + attr_reader :column + + # Returns a best effort JSONPath string representing where in the document + # the parser encountered an error: + # + # begin + # JSON.parse('{"articles": [ { "title": invalid } ]}') + # rescue JSON::ParserError => error + # error.json_path # => "$.articles[0].title" + # end + def json_path + return @json_path if String === @json_path + + if Array === @json_path + path = build_json_path(@json_path) + @json_path = path unless frozen? + return path + end + end + + private + + def build_json_path(segments) + error = false + path = segments.filter_map do |segment| + next if error + + case segment + when Integer + "[#{segment}]" + when String, Symbol + if segment.match?(/\A[a-zA-Z\$\_][a-zA-Z\$\_0-9]*\z/) + ".#{segment}" + else + segment = segment.to_s.gsub(/["\\]/, { '"' => '\\"', '\\' => '\\\\' }) + %{["#{segment}"]} + end + else + error = true + nil + end + end.join + "$#{path}".freeze + end end # This exception is raised if the nesting of parsed data structures is too diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index 9568b68fe..84839585f 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -856,8 +856,14 @@ def test_parse_error_json_path assert_parse_error_at "$.a[1].b", '{"a": [1, {"b": xyz}]}' assert_parse_error_at "$.a", '{"a": 1 xyz}' assert_parse_error_at "$", '{"a": 1, xyz}' + + assert_parse_error_at "$.a.b.c", '{"a": {"b": {"c"' assert_parse_error_at "$.a.b.c", '{"a": {"b": {"c":' + assert_parse_error_at "$.a.b", '{"a": {"b": {"c": 1, "d' + + assert_parse_error_at "$[4]", '[1,2,3,4,5' assert_parse_error_at "$[5]", '[1,2,3,4,5,' + assert_parse_error_at "$[5]", '[1,2,3,4,5,]' end def test_parse_error_json_path_on_load