diff --git a/CHANGES.md b/CHANGES.md index 2bad52ae..732df322 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ * Fix `JSON::ParserError#json_path` raising `NoMethodError` when the document was parsed with `symbolize_names: true`. * Fix `JSON.generate` not detecting duplicate keys such as `{ "1" => 1, 1 => 2 }`. * JRuby: raise `TypeError` when a String subclass used as a Hash key has a `to_s` that doesn't return a String, like the C extension. +* JRuby: parse error messages quote the same snippet as the C extension, stopping at whitespace and never splitting a multibyte character. ### 2026-10-13 (3.1.0) diff --git a/java/src/json/ext/Parser.java b/java/src/json/ext/Parser.java index eb5ce3a9..cc066665 100644 --- a/java/src/json/ext/Parser.java +++ b/java/src/json/ext/Parser.java @@ -195,6 +195,7 @@ private IRubyObject createCustomDecimal(final ThreadContext context, final ByteL private static final class ParserSession { private static final int INITIAL_FRAME_CAPACITY = 32; private static final int INITIAL_VALUE_CAPACITY = 64; + private static final int PARSE_ERROR_FRAGMENT_LEN = 32; // Integers with fewer than this many digits are built directly from a // long accumulated during the digit scan; longer ones fall back to @@ -304,7 +305,7 @@ public IRubyObject parse(ThreadContext context) { // Only trailing whitespace (and comments) may follow the document. eatWhitespace(); if (cursor < end) { - throw unexpectedToken(cursor, end); + throw parsingError("unexpected token at end of stream ", cursor, end); } return result; } @@ -392,7 +393,7 @@ private IRubyObject run() { case 0: throw parseError("unexpected end of input"); default: - throw unexpectedToken(cursor, end); + throw parsingError("unexpected character: ", cursor, end); } pushValue(value); @@ -958,16 +959,36 @@ private RaiseException parseError(RubyString message, IRubyObject duplicateKey) return error; } - private RaiseException parsingError(int absStart, int absEnd) { + private RaiseException parsingError(String prefix, int absStart, int absEnd) { cursor = absStart; - RubyString msg = context.runtime.newString("unexpected token at '") - .cat(data, absStart, Math.min(absEnd - absStart, 32)) - .cat((byte)'\''); + RubyString msg = context.runtime.newString(prefix); + if (absStart >= absEnd) { + msg.catString("EOF"); + return parseError(msg, null); + } + + // Like the C parser, quote the source up to the next whitespace, + // at most PARSE_ERROR_FRAGMENT_LEN bytes. + int len = 0; + while (len < PARSE_ERROR_FRAGMENT_LEN && absStart + len < absEnd) { + byte b = data[absStart + len]; + if (b == 0 || b == '\n' || b == ' ' || b == '\t' || b == '\r') { + break; + } + len++; + } + if (len == PARSE_ERROR_FRAGMENT_LEN) { + // Only trim when the byte limit splits a multibyte character. + while (len > 0 && absStart + len < absEnd && (data[absStart + len] & 0xC0) == 0x80) { + len--; + } + } + msg.cat((byte)'\'').cat(data, absStart, len).cat((byte)'\''); return parseError(msg, null); } private RaiseException unexpectedToken(int absStart, int absEnd) { - return parsingError(absStart, absEnd); + return parsingError("unexpected token ", absStart, absEnd); } private RaiseException newException(String className, String message) { diff --git a/test/json/json_ext_parser_test.rb b/test/json/json_ext_parser_test.rb index 8b10146a..30e2b8fd 100644 --- a/test/json/json_ext_parser_test.rb +++ b/test/json/json_ext_parser_test.rb @@ -16,14 +16,10 @@ def test_allocate def test_error_messages ex = assert_raise(ParserError) { parse('Infinity something') } - unless RUBY_PLATFORM =~ /java/ - assert_equal "unexpected token 'Infinity' at line 1 column 1", ex.message - end + assert_equal "unexpected token 'Infinity' at line 1 column 1", ex.message ex = assert_raise(ParserError) { parse('foo bar') } - unless RUBY_PLATFORM =~ /java/ - assert_equal "unexpected token 'foo' at line 1 column 1", ex.message - end + assert_equal "unexpected token 'foo' at line 1 column 1", ex.message ex = assert_raise(ParserError) { parse('-Infinity something') } unless RUBY_PLATFORM =~ /java/ @@ -31,9 +27,7 @@ def test_error_messages end ex = assert_raise(ParserError) { parse('NaN something') } - unless RUBY_PLATFORM =~ /java/ - assert_equal "unexpected token 'NaN' at line 1 column 1", ex.message - end + assert_equal "unexpected token 'NaN' at line 1 column 1", ex.message ex = assert_raise(ParserError) { parse(' ') } unless RUBY_PLATFORM =~ /java/ diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index 0549f6f4..448e6eee 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -867,8 +867,6 @@ def test_parse_error_incomplete_hash end def test_parse_error_snippet - omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" - error = assert_raise(JSON::ParserError) { JSON.parse("あああああああああああああああああああああああ") } assert_equal "unexpected character: 'ああああああああああ' at line 1 column 1", error.message @@ -898,8 +896,6 @@ def test_parse_error_snippet end def test_parse_error_snippet_multibyte_boundaries - omit "JRuby errors don't contain positions" if RUBY_ENGINE == "jruby" - ['é', '日', '😀'].each do |char| ['', ' ', "\n", "\t", "\r"].each do |suffix| error = assert_raise(JSON::ParserError) { JSON.parse(char + suffix) }