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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: parser errors raised while decoding a string, such as an invalid escape or a raw control character, now include the position (`line`, `column` and the message suffix) and `json_path`, matching the C extension.

### 2026-10-13 (3.1.0)

Expand Down
4 changes: 2 additions & 2 deletions java/src/json/ext/ByteListTranscoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ protected void quoteStop(int endPos) throws IOException {
protected abstract void append(byte[] origin, int start, int length) throws IOException;


protected abstract RaiseException invalidUtf8(ThreadContext context);
protected abstract RuntimeException invalidUtf8(ThreadContext context);

protected RaiseException incompleteUtf8(ThreadContext context) {
protected RuntimeException incompleteUtf8(ThreadContext context) {
return invalidUtf8(context);
}
}
14 changes: 8 additions & 6 deletions java/src/json/ext/Parser.java

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 68 additions & 39 deletions java/src/json/ext/StringDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/
package json.ext;

import org.jruby.exceptions.RaiseException;
import org.jruby.runtime.ThreadContext;
import org.jruby.util.ByteList;

Expand All @@ -19,10 +18,31 @@
*/
final class StringDecoder extends ByteListTranscoder {
/**
* Stores the offset of the high surrogate when reading a surrogate pair,
* or -1 when not.
* A decoding error, with the offset in the source ByteList (relative to
* its begin) that the parser reports as the error's line and column.
*/
private int surrogatePairStart = -1;
static final class DecodeError extends RuntimeException {
final ByteList message;
final int offset;

DecodeError(ByteList message, int offset) {
super(null, null, false, false);
this.message = message;
this.offset = offset;
}
}

/**
* Stores the offset of the backslash that starts the escape sequence
* being read, or -1 when not in one.
*/
private int escapeStart = -1;

/**
* Stores the offset where the text after the last escape sequence (or the
* string) starts. The C parser reports surrogate errors there.
*/
private int segmentStart;
private boolean allowControlCharacters = false;
private boolean allowInvalidEscape = false;

Expand All @@ -42,6 +62,8 @@ ByteList decode(ThreadContext context, ByteList src, int start, int end) {
try {
init(src, start, end);
this.out = new ByteList(end - start);
escapeStart = -1;
segmentStart = start;
while (hasNext()) {
handleChar(context, readUtf8Char(context));
}
Expand All @@ -58,6 +80,8 @@ ByteList decodeNoValidate(ThreadContext context, ByteList src, int start, int en
try {
init(src, start, end);
this.out = new ByteList(end - start);
escapeStart = -1;
segmentStart = start;
final byte[] data = src.unsafeBytes();
final int base = src.begin();
final int absEnd = base + srcEnd;
Expand Down Expand Up @@ -114,8 +138,9 @@ private void handleChar(ThreadContext context, int c) throws IOException {
}

private void handleEscapeSequence(ThreadContext context) throws IOException {
escapeStart = charStart;
ensureMin(context, 1);
int character = readUtf8Char(context);
int character = super.readUtf8Char(context);
switch (character) {
case 'b':
append('\b');
Expand Down Expand Up @@ -148,18 +173,23 @@ private void handleEscapeSequence(ThreadContext context) throws IOException {
handleLowSurrogate(context, (char)cp);
} else if (Character.isLowSurrogate((char)cp)) {
// low surrogate with no high surrogate
throw invalidUtf8(context);
throw invalidSurrogate();
} else {
writeUtf8Char(cp);
}
break;
default:
if (character < 0x20 && !allowControlCharacters) {
throw invalidControlChar(context, escapeStart);
}
if (allowInvalidEscape) {
writeUtf8Char(character);
} else {
throw invalidEscape(context);
}
}
escapeStart = -1;
segmentStart = pos;
}

protected void append(int b) throws IOException {
Expand All @@ -171,22 +201,20 @@ protected void append(byte[] origin, int start, int length) throws IOException {
}

private void handleLowSurrogate(ThreadContext context, char highSurrogate) throws IOException {
surrogatePairStart = charStart;
ensureMin(context, 1);
if (pos + 1 > srcEnd) throw invalidSurrogate();
int lowSurrogate = readUtf8Char(context);

if (lowSurrogate == '\\') {
ensureMin(context, 5);
if (readUtf8Char(context) != 'u') throw invalidUtf8(context);
if (pos + 5 > srcEnd) throw invalidSurrogate();
if (readUtf8Char(context) != 'u') throw invalidSurrogate();
lowSurrogate = readHex(context);
}

if (Character.isLowSurrogate((char)lowSurrogate)) {
writeUtf8Char(Character.toCodePoint(highSurrogate,
(char)lowSurrogate));
surrogatePairStart = -1;
} else {
throw invalidUtf8(context);
throw invalidSurrogate();
}
}

Expand Down Expand Up @@ -239,40 +267,41 @@ private int readHex(ThreadContext context) {
return result;
}

// The offsets follow the C parser: errors in an escape sequence are
// reported at its backslash, surrogate errors at the text before it, and
// raw control characters where they are. The message prefixes are unchanged.

@Override
protected RaiseException invalidUtf8(ThreadContext context) {
ByteList message = new ByteList(
ByteList.plain("partial character in source, " +
"but hit end near "));
int start = surrogatePairStart != -1 ? surrogatePairStart : charStart;
message.append(src, start, srcEnd - start);
return Utils.newException(context, Utils.M_PARSER_ERROR,
context.runtime.newString(message));
protected DecodeError invalidUtf8(ThreadContext context) {
int start = escapeStart != -1 ? escapeStart : charStart;
return error("partial character in source, but hit end near ", start, start);
}

protected DecodeError invalidControlChar(ThreadContext context) {
return invalidControlChar(context, charStart);
}

protected RaiseException invalidControlChar(ThreadContext context) {
ByteList message = new ByteList(
ByteList.plain("invalid ASCII control character in string: "));
message.append(src, charStart, srcEnd - charStart);
return Utils.newException(context, Utils.M_PARSER_ERROR,
context.runtime.newString(message));
private DecodeError invalidControlChar(ThreadContext context, int offset) {
return error("invalid ASCII control character in string: ", charStart, offset);
}

protected RaiseException invalidEscape(ThreadContext context) {
ByteList message = new ByteList(
ByteList.plain("invalid escape character in string: "));
message.append(src, charStart, srcEnd - charStart);
return Utils.newException(context, Utils.M_PARSER_ERROR,
context.runtime.newString(message));
private DecodeError invalidSurrogate() {
return error("partial character in source, but hit end near ", segmentStart, segmentStart);
}

protected RaiseException invalidUnicodeEscape(ThreadContext context, int escapeStart) {
ByteList message = new ByteList(
ByteList.plain("incomplete unicode character escape sequence at "));
protected DecodeError invalidEscape(ThreadContext context) {
return error("invalid escape character in string: ", charStart, escapeStart);
}

protected DecodeError invalidUnicodeEscape(ThreadContext context, int numberStart) {
// Point at the backslash-u that introduced the escape.
int start = Math.max(escapeStart - 2, 0);
message.append(src, start, srcEnd - start);
return Utils.newException(context, Utils.M_PARSER_ERROR,
context.runtime.newString(message));
int start = Math.max(numberStart - 2, 0);
return error("incomplete unicode character escape sequence at ", start, start);
}

private DecodeError error(String prefix, int snippetStart, int offset) {
ByteList message = new ByteList(ByteList.plain(prefix));
message.append(src, snippetStart, srcEnd - snippetStart);
return new DecodeError(message, offset);
}
}
17 changes: 15 additions & 2 deletions test/json/json_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -948,8 +948,6 @@ def test_parse_error_position
end

def test_parse_error_position_at_newline
omit "JRuby string decoding errors don't contain positions" if RUBY_ENGINE == "jruby"

["[1,\n\"ab\ncd\"]", "[1,\n\"日本\ncd\"]"].each do |json|
error = assert_raise(JSON::ParserError) { JSON.parse(json) }
assert_equal 2, error.line
Expand All @@ -958,6 +956,21 @@ def test_parse_error_position_at_newline
end
end

def test_parse_error_position_in_string_escapes
{
%([1,\n"\\q"]) => [2, 2],
%([1,\n"ab\\u12"]) => [2, 4],
%([1,\n"\\u12\n4"]) => [2, 6],
%([1,\n"ab\\ud800x"]) => [2, 2],
%([1,\n"x\\n\\udc00"]) => [2, 5],
%(["日本\\q"]) => [1, 5],
}.each do |json, (line, column)|
error = assert_raise(JSON::ParserError) { JSON.parse(json) }
assert_equal [line, column], [error.line, error.column], json.inspect
assert_include error.message, "at line #{line} column #{column}"
end
end

def test_parse_error_position_with_multibyte_characters
{
'["日本", @]' => [1, 8],
Expand Down
Loading