Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 44 additions & 9 deletions ext/json/ext/parser/parser.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Comment thread
byroot marked this conversation as resolved.
} 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;
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down
51 changes: 50 additions & 1 deletion lib/json/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tt>nil</tt> when raised by JSON::ResumableParser.
attr_reader :line

# Column number where the parser encountered an error.
# Is <tt>nil</tt> 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
Expand Down
85 changes: 85 additions & 0 deletions test/json/json_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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