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..fc6a7bbf6 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,42 @@ static VALUE build_parse_error_message(const char *format, JSON_ParserState *sta return rb_enc_sprintf(enc_utf8, format, ptr); } +static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key) +{ + 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_ary_push(path, LONG2NUM(frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count)); + } else if (innermost && !UNDEF_P(duplicate_key)) { + rb_ary_push(path, duplicate_key); + } else if (count & 1) { + rb_ary_push(path, values->ptr[child_head - 1]); + } else if (frame->phase == JSON_PHASE_OBJECT_COMMA && count >= 2) { + rb_ary_push(path, values->ptr[child_head - 2]); + } else { + break; + } + } + + 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 +1230,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 +2156,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 +2170,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 +2905,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..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 + # 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 e7940faa4..84839585f 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -847,6 +847,80 @@ 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 "$.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 + 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 + else + obj + 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 + 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 +962,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