Skip to content

Commit fbd308c

Browse files
ojuschugh1byroot
authored andcommitted
Add JSON::ParserError#json_path
1 parent 0479b66 commit fbd308c

4 files changed

Lines changed: 134 additions & 10 deletions

File tree

‎CHANGES.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
### Unreleased
44

5+
* 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.
6+
57
### 2026-08-11 (3.0.0.rc1)
68

79
With the removal of the insecure `create_additions` option, `JSON.load` and `JSON.dump` are

‎ext/json/ext/parser/parser.c‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
static VALUE mJSON, eNestingError, eParserError, Encoding_UTF_8;
66
static VALUE CNaN, CInfinity, CMinusInfinity, JSON_empty_string;
77

8-
static ID i_new, i_try_convert, i_encode, i_at_line, i_at_column;
8+
static ID i_new, i_try_convert, i_encode, i_at_line, i_at_column, i_at_json_path;
99
#ifndef HAVE_RB_STR_TO_INTERNED_STR
1010
static ID i_uminus;
1111
#endif
@@ -651,11 +651,74 @@ static VALUE build_parse_error_message(const char *format, JSON_ParserState *sta
651651
return rb_enc_sprintf(enc_utf8, format, ptr);
652652
}
653653

654+
static void json_path_append_key(VALUE path, VALUE key)
655+
{
656+
if (RB_SYMBOL_P(key)) {
657+
key = rb_sym2str(key);
658+
}
659+
660+
if (!RB_TYPE_P(key, T_STRING)) {
661+
rb_str_catf(path, "[%+"PRIsVALUE"]", key);
662+
return;
663+
}
664+
665+
long len = RSTRING_LEN(key);
666+
bool plain = len > 0;
667+
for (long i = 0; plain && i < len; i++) {
668+
char c = RSTRING_PTR(key)[i];
669+
bool alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
670+
plain = alpha || (i > 0 && c >= '0' && c <= '9');
671+
}
672+
673+
if (plain) {
674+
rb_str_cat_cstr(path, ".");
675+
rb_str_cat(path, RSTRING_PTR(key), len);
676+
return;
677+
}
678+
679+
rb_str_cat_cstr(path, "[\"");
680+
for (long i = 0; i < RSTRING_LEN(key); i++) {
681+
char c = RSTRING_PTR(key)[i];
682+
if (c == '"' || c == '\\') {
683+
rb_str_cat(path, "\\", 1);
684+
}
685+
rb_str_cat(path, &c, 1);
686+
}
687+
rb_str_cat_cstr(path, "\"]");
688+
}
689+
690+
static VALUE json_path_new(JSON_ParserState *state, VALUE duplicate_key)
691+
{
692+
VALUE path = rb_utf8_str_new("$", 1);
693+
json_frame_stack *frames = state->frames;
694+
rvalue_stack *values = state->value_stack;
695+
696+
for (long depth = 1; depth < frames->head; depth++) {
697+
json_frame *frame = &frames->ptr[depth];
698+
bool innermost = depth == frames->head - 1;
699+
long child_head = innermost ? values->head : frames->ptr[depth + 1].value_stack_head;
700+
long count = child_head - frame->value_stack_head;
701+
702+
if (frame->type == JSON_FRAME_ARRAY) {
703+
rb_str_catf(path, "[%ld]", frame->phase == JSON_PHASE_ARRAY_COMMA ? count - 1 : count);
704+
} else if (innermost && !UNDEF_P(duplicate_key)) {
705+
json_path_append_key(path, duplicate_key);
706+
} else if (count & 1) {
707+
json_path_append_key(path, values->ptr[child_head - 1]);
708+
} else if (frame->phase == JSON_PHASE_OBJECT_COMMA && count >= 2) {
709+
json_path_append_key(path, values->ptr[child_head - 2]);
710+
}
711+
}
712+
713+
return path;
714+
}
715+
654716
static VALUE parse_error_new(JSON_ParserState *state, VALUE message, long line, long column, bool eos)
655717
{
656718
VALUE exc = rb_exc_new_str(eParserError, message);
657719
rb_ivar_set(exc, i_at_line, LONG2NUM(line));
658720
rb_ivar_set(exc, i_at_column, LONG2NUM(column));
721+
rb_ivar_set(exc, i_at_json_path, json_path_new(state, Qundef));
659722
return exc;
660723
}
661724

@@ -1199,14 +1262,17 @@ NORETURN(static) void raise_duplicate_key_error(JSON_ParserState *state, VALUE d
11991262
);
12001263

12011264
rb_str_concat(message, build_parse_error_message("", state));
1265+
VALUE exc;
12021266
if (state->parser) { // line and columns can't be accurate in resumable
1203-
rb_exc_raise(parse_error_new(state, message, 0, 0, false));
1267+
exc = parse_error_new(state, message, 0, 0, false);
12041268
} else {
12051269
long line, column;
12061270
cursor_position(state, &line, &column);
12071271
rb_str_catf(message, " at line %ld column %ld", line, column);
1208-
rb_exc_raise(parse_error_new(state, message, line, column, false));
1272+
exc = parse_error_new(state, message, line, column, false);
12091273
}
1274+
rb_ivar_set(exc, i_at_json_path, json_path_new(state, duplicate_key));
1275+
rb_exc_raise(exc);
12101276
}
12111277

12121278
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)
21222188
// the rvalue stack.
21232189
VALUE result = complete ? *rvalue_stack_peek(state->value_stack, 1) : Qundef;
21242190

2191+
if (complete) {
2192+
json_ensure_eof(state, config);
2193+
} else {
2194+
raise_eos_error("unexpected end of input", state);
2195+
}
2196+
21252197
// This may be skipped in case of exception, but
21262198
// it won't cause a leak.
21272199
rvalue_stack_eagerly_release(value_stack_handle);
@@ -2130,12 +2202,6 @@ static VALUE cParser_parse(JSON_ParserConfig *config, VALUE src)
21302202
RB_GC_GUARD(frame_stack_handle);
21312203
RB_GC_GUARD(Vsource);
21322204

2133-
if (complete) {
2134-
json_ensure_eof(state, config);
2135-
} else {
2136-
raise_eos_error("unexpected end of input", state);
2137-
}
2138-
21392205
return result;
21402206
}
21412207

@@ -2871,6 +2937,7 @@ void Init_parser(void)
28712937
i_encode = rb_intern("encode");
28722938
i_at_line = rb_intern("@line");
28732939
i_at_column = rb_intern("@column");
2940+
i_at_json_path = rb_intern("@json_path");
28742941

28752942
binary_encindex = rb_ascii8bit_encindex();
28762943
utf8_encindex = rb_utf8_encindex();

‎lib/json/common.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ class JSONError < StandardError; end
142142

143143
# This exception is raised if a parser error occurs.
144144
class ParserError < JSONError
145-
attr_reader :line, :column
145+
attr_reader :line, :column, :json_path
146146
end
147147

148148
# This exception is raised if the nesting of parsed data structures is too

‎test/json/json_parser_test.rb‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,50 @@ def test_parse_error_snippet
847847
assert_equal "unexpected character: '@' at line 1 column 1", error.message
848848
end
849849

850+
def test_parse_error_json_path
851+
omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby"
852+
853+
assert_parse_error_at "$", "xyz"
854+
assert_parse_error_at "$.a", '{"a": xyz}'
855+
assert_parse_error_at "$[3]", '[1, 2, "hi", xyz]'
856+
assert_parse_error_at "$.a[1].b", '{"a": [1, {"b": xyz}]}'
857+
assert_parse_error_at "$.a", '{"a": 1 xyz}'
858+
assert_parse_error_at "$", '{"a": 1, xyz}'
859+
assert_parse_error_at "$.a.b.c", '{"a": {"b": {"c":'
860+
assert_parse_error_at "$[5]", '[1,2,3,4,5,'
861+
end
862+
863+
def test_parse_error_json_path_key_escaping
864+
omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby"
865+
866+
assert_parse_error_at '$["hello world"]', '{"hello world": xyz}'
867+
assert_parse_error_at '$["a\"b"]', '{"a\"b": xyz}'
868+
assert_parse_error_at '$[""]', '{"": xyz}'
869+
assert_parse_error_at '$["あ"]', '{"あ": xyz}'
870+
assert_parse_error_at '$.foo["1x"]', '{"foo": {"1x": xyz}}'
871+
end
872+
873+
def test_parse_error_json_path_duplicate_key
874+
omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby"
875+
876+
assert_parse_error_at "$.a", '{"a": 1, "a": 2}'
877+
assert_parse_error_at "$.x.a", '{"x": {"a": 1, "b": 2, "a": 3}}'
878+
assert_parse_error_at "$.arr[0].a", '{"arr": [{"a": 1, "a": 2}]}'
879+
assert_parse_error_at "$.x.a", '{"x": {"a": 1, "a": 2}}'
880+
end
881+
882+
def test_parse_error_json_path_resumable
883+
omit "JSON::ResumableParser not available" unless defined?(JSON::ResumableParser)
884+
885+
parser = JSON::ResumableParser.new
886+
parser << '{"a": [1, {"b": '
887+
parser.parse
888+
assert_parse_error_at "$.a[1].b" do
889+
parser << 'xyz'
890+
parser.parse
891+
end
892+
end
893+
850894
def test_parse_leading_slash
851895
# ref: https://github.com/ruby/ruby/pull/12598
852896
assert_raise(JSON::ParserError) do
@@ -888,4 +932,15 @@ def assert_equal_float(expected, actual, delta = 1e-2)
888932
Array === actual and actual = actual.first
889933
assert_in_delta(expected, actual, delta)
890934
end
935+
936+
def assert_parse_error_at(path, json = nil)
937+
error = assert_raise(JSON::ParserError) do
938+
if block_given?
939+
yield
940+
else
941+
JSON.parse(json)
942+
end
943+
end
944+
assert_equal path, error.json_path
945+
end
891946
end

0 commit comments

Comments
 (0)