diff --git a/CHANGES.md b/CHANGES.md index 8deebca79..ee8c9374d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ ### 2026-09-07 (3.0.0) * 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. +* JRuby: parser errors now include the position (`line`, `column` and the message suffix) as well as `json_path`, matching the C extension. * Fix the parser to also reject lone trailing UTF-16 surrogates (`\uDCxx` with no leading partner), symmetric to the leading-surrogate case. The Java parser already rejected these; this closes the CRuby/JRuby parity gap. ### 2026-08-11 (3.0.0.rc1) diff --git a/java/src/json/ext/Parser.java b/java/src/json/ext/Parser.java index 59ebda179..92712ba27 100644 --- a/java/src/json/ext/Parser.java +++ b/java/src/json/ext/Parser.java @@ -6,6 +6,7 @@ import org.jruby.Ruby; import org.jruby.RubyArray; import org.jruby.RubyClass; +import org.jruby.RubyException; import org.jruby.RubyFloat; import org.jruby.RubyHash; import org.jruby.RubyObject; @@ -376,7 +377,7 @@ private IRubyObject run() { continue; } case 0: - throw newException(Utils.M_PARSER_ERROR, "unexpected end of input"); + throw parseError("unexpected end of input"); default: throw unexpectedToken(cursor, end); } @@ -572,7 +573,7 @@ private void onDuplicateKey(IRubyObject key) { // match the C parser's message. String keyInspect = key.callMethod(context, "to_s") .callMethod(context, "inspect").asJavaString(); - throw newException(Utils.M_PARSER_ERROR, "duplicate key " + keyInspect); + throw parseError(context.runtime.newString("duplicate key " + keyInspect), key); } private int parseDigits(long value) { @@ -667,8 +668,7 @@ private IRubyObject parseString(boolean isName) { long scanned = scanner.scan(data, chunks, contentStart, end); final int q = (int) scanned; if (q < 0) { - throw newException(Utils.M_PARSER_ERROR, - "unexpected end of input, expected closing \""); + throw parseError("unexpected end of input, expected closing \""); } boolean plain = (scanned & StringScanner.PLAIN_BIT) != 0; @@ -853,8 +853,7 @@ private void eatComments() { while (true) { while (cursor < end && data[cursor] != '*') cursor++; if (cursor >= end) { - throw newException(Utils.M_PARSER_ERROR, - "unterminated comment, expected closing '*/'"); + throw parseError("unterminated comment, expected closing '*/'"); } cursor++; // past '*' if (peek() == '/') { @@ -883,11 +882,77 @@ private IRubyObject getConstant(String name) { return info.jsonModule.get().getConstant(name); } + private long cursorPosition(int position) { + long column = 0; + int i = position; + if (i >= end) { + column = i - end + 1; + i = end - 1; + } + int line = 1; + while (i >= begin) { + if (data[i--] == '\n') { + line++; + break; + } + column++; + } + while (i >= begin) { + if (data[i--] == '\n') { + line++; + } + } + return ((long) line << 32) | column; + } + + private RubyArray jsonPathSegments(IRubyObject duplicateKey) { + Ruby runtime = context.runtime; + RubyArray segments = RubyArray.newArray(runtime); + for (int depth = 1; depth < frameDepth; depth++) { + Frame frame = frameStack[depth]; + boolean innermost = depth == frameDepth - 1; + int childHead = innermost ? valueTop : frameStack[depth + 1].valueStackHead; + int count = childHead - frame.valueStackHead; + + if (frame.type == FrameType.ARRAY) { + segments.append(runtime.newFixnum(frame.phase == FramePhase.ARRAY_COMMA ? count - 1 : count)); + } else if (innermost && duplicateKey != null) { + segments.append(duplicateKey); + } else if ((count & 1) == 1) { + segments.append(valueStack[childHead - 1]); + } else if (frame.phase == FramePhase.OBJECT_COMMA && count >= 2) { + segments.append(valueStack[childHead - 2]); + } else { + break; + } + } + return segments; + } + + private RaiseException parseError(String message) { + return parseError(context.runtime.newString(message), null); + } + + private RaiseException parseError(RubyString message, IRubyObject duplicateKey) { + Ruby runtime = context.runtime; + long position = cursorPosition(cursor); + int line = (int) (position >>> 32); + int column = (int) position; + message.catString(" at line " + line + " column " + column); + RaiseException error = Utils.newException(context, Utils.M_PARSER_ERROR, message); + RubyException exception = error.getException(); + exception.setInstanceVariable("@line", runtime.newFixnum(line)); + exception.setInstanceVariable("@column", runtime.newFixnum(column)); + exception.setInstanceVariable("@json_path", jsonPathSegments(duplicateKey)); + return error; + } + private RaiseException parsingError(int absStart, int absEnd) { + cursor = absStart; RubyString msg = context.runtime.newString("unexpected token at '") .cat(data, absStart, Math.min(absEnd - absStart, 32)) .cat((byte)'\''); - return newException(Utils.M_PARSER_ERROR, msg); + return parseError(msg, null); } private RaiseException unexpectedToken(int absStart, int absEnd) { @@ -897,9 +962,5 @@ private RaiseException unexpectedToken(int absStart, int absEnd) { private RaiseException newException(String className, String message) { return Utils.newException(context, className, message); } - - private RaiseException newException(String className, RubyString message) { - return Utils.newException(context, className, message); - } } } diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index bd167d9c8..2fd7e2745 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -852,8 +852,6 @@ def test_parse_error_snippet 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]' @@ -870,9 +868,17 @@ def test_parse_error_json_path 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" + def test_parse_error_position + error = assert_raise(JSON::ParserError) { JSON.parse("[1,\n@") } + assert_equal 2, error.line + assert_equal 1, error.column + + error = assert_raise(JSON::ParserError) { JSON.parse('{"a": {"b": [1, {"c": 1, "c": 2}]}}') } + assert_equal 1, error.line + assert_equal 17, error.column + end + def test_parse_error_json_path_on_load assert_parse_error_at "$" do JSON.load('{"a": {"b": {"c":', -> (obj) { if String === obj @@ -895,8 +901,6 @@ def test_parse_error_json_path_on_load 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}' @@ -905,8 +909,6 @@ def test_parse_error_json_path_key_escaping 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}]}'