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