add JsonParser
This commit is contained in:
parent
7e44eda639
commit
1da002ce70
@ -1,6 +1,8 @@
|
||||
package eu.jonahbauer.json;
|
||||
|
||||
import eu.jonahbauer.json.exceptions.JsonConversionException;
|
||||
import eu.jonahbauer.json.exceptions.JsonParserException;
|
||||
import eu.jonahbauer.json.parser.JsonParser;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@ -41,6 +43,17 @@ public sealed interface JsonValue extends Serializable permits JsonObject, JsonA
|
||||
return value == null ? "null" : value.toPrettyJsonString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string as a JSON. The string must adhere to the <a href="https://www.json.org">JSON grammar</a>.
|
||||
* @param json a string
|
||||
* @return the result of parsing the string as a JSON
|
||||
* @throws JsonParserException if the string cannot be parsed as JSON
|
||||
*/
|
||||
static @Nullable JsonValue parse(@NotNull String json) {
|
||||
var parser = new JsonParser(json);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an object to a JSON value:
|
||||
* <ul>
|
||||
|
@ -0,0 +1,14 @@
|
||||
package eu.jonahbauer.json.exceptions;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class JsonParserException extends JsonReaderException {
|
||||
|
||||
public JsonParserException(int line, int column, @NotNull String message) {
|
||||
super(line, column, message);
|
||||
}
|
||||
|
||||
public JsonParserException(int line, int column, @NotNull String message, @NotNull Throwable cause) {
|
||||
super(line, column, message, cause);
|
||||
}
|
||||
}
|
242
core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java
Normal file
242
core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java
Normal file
@ -0,0 +1,242 @@
|
||||
package eu.jonahbauer.json.parser;
|
||||
|
||||
import eu.jonahbauer.json.*;
|
||||
import eu.jonahbauer.json.exceptions.JsonParserException;
|
||||
import eu.jonahbauer.json.exceptions.JsonTokenizerException;
|
||||
import eu.jonahbauer.json.tokenizer.JsonTokenizer;
|
||||
import eu.jonahbauer.json.tokenizer.JsonTokenizerImpl;
|
||||
import eu.jonahbauer.json.tokenizer.token.JsonNull;
|
||||
import eu.jonahbauer.json.tokenizer.token.JsonPunctuation;
|
||||
import eu.jonahbauer.json.tokenizer.token.JsonToken;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.*;
|
||||
|
||||
public final class JsonParser {
|
||||
private final @NotNull JsonTokenizer tokenizer;
|
||||
|
||||
public JsonParser(@NotNull String json) {
|
||||
this(new JsonTokenizerImpl(json));
|
||||
}
|
||||
|
||||
public JsonParser(@NotNull Reader reader) {
|
||||
this(new JsonTokenizerImpl(reader));
|
||||
}
|
||||
|
||||
public JsonParser(@NotNull JsonTokenizer tokenizer) {
|
||||
this.tokenizer = Objects.requireNonNull(tokenizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the input as a JSON value.
|
||||
* @return the JSON value
|
||||
* @throws JsonParserException if the input could not be parsed as JSON
|
||||
* @throws UncheckedIOException if an I/O error occurs
|
||||
*/
|
||||
public @Nullable JsonValue parse() throws JsonParserException {
|
||||
try {
|
||||
var out = parse0();
|
||||
if (tokenizer.next() != null) {
|
||||
throw new JsonParserException(tokenizer.getLineNumber(), tokenizer.getColumnNumber(), "unexpected trailing entries");
|
||||
}
|
||||
return out;
|
||||
} catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
} catch (JsonTokenizerException ex) {
|
||||
throw new JsonParserException(ex.getLineNumber(), ex.getColumnNumber(), ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable JsonValue parse0() throws IOException {
|
||||
// Use a stack of Context objects to store the objects and arrays which are currently being parsed.
|
||||
// This avoids the risk of stack overflow which would exist when having one nested calls to parse0.
|
||||
var stack = new ArrayDeque<@NotNull Context>();
|
||||
|
||||
while (true) {
|
||||
JsonValue value;
|
||||
|
||||
var token = tokenizer.next();
|
||||
if (!stack.isEmpty() && token == stack.peek().end()) {
|
||||
value = stack.pop().build();
|
||||
} else {
|
||||
// consume value separator
|
||||
if (stack.peek() instanceof Context context && !context.isEmpty()) {
|
||||
if (token != JsonPunctuation.VALUE_SEPARATOR) {
|
||||
throw unexpectedToken(token, context.end(), JsonPunctuation.VALUE_SEPARATOR);
|
||||
}
|
||||
token = tokenizer.next();
|
||||
}
|
||||
|
||||
// read object key and consume name separator
|
||||
if (stack.peek() instanceof ObjectContext object) {
|
||||
var key = parseJsonObjectKey(object, token);
|
||||
if ((token = tokenizer.next()) != JsonPunctuation.NAME_SEPARATOR) {
|
||||
throw unexpectedToken(token, JsonPunctuation.NAME_SEPARATOR);
|
||||
}
|
||||
token = tokenizer.next();
|
||||
object.setKey(key);
|
||||
}
|
||||
|
||||
if (token == JsonPunctuation.BEGIN_OBJECT) {
|
||||
stack.push(new ObjectContext());
|
||||
continue;
|
||||
} else if (token == JsonPunctuation.BEGIN_ARRAY) {
|
||||
stack.push(new ArrayContext());
|
||||
continue;
|
||||
} else {
|
||||
// read json value
|
||||
value = switch (token) {
|
||||
case JsonPunctuation.BEGIN_ARRAY, JsonPunctuation.BEGIN_OBJECT -> throw new AssertionError();
|
||||
case JsonNull.NULL -> null;
|
||||
case JsonValue v -> v;
|
||||
case JsonPunctuation _ -> throw unexpectedStartOfValue(token);
|
||||
case null -> throw unexpectedEndOfFile();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.peek() instanceof Context context) {
|
||||
context.add(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull String parseJsonObjectKey(@NotNull ObjectContext context, @Nullable JsonToken token) {
|
||||
return switch (token) {
|
||||
case JsonString string -> string.value();
|
||||
case null -> throw unexpectedEndOfFile();
|
||||
default -> throw unexpectedStartOfKey(context, token);
|
||||
};
|
||||
}
|
||||
|
||||
private @NotNull JsonParserException unexpectedStartOfKey(@NotNull ObjectContext context, @Nullable JsonToken token) {
|
||||
if (context.isEmpty()) {
|
||||
throw unexpectedToken(token, "JSON_STRING", JsonPunctuation.END_OBJECT);
|
||||
} else {
|
||||
// if the context is not empty, we have already consumed a VALUE_SEPARATOR(,) and standard JSON does not
|
||||
// allow trailing commas
|
||||
throw unexpectedToken(token, "JSON_STRING");
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull JsonParserException unexpectedStartOfValue(@Nullable JsonToken token) {
|
||||
var expected = new Object[] {
|
||||
JsonPunctuation.BEGIN_OBJECT, JsonPunctuation.BEGIN_ARRAY, JsonBoolean.TRUE, JsonBoolean.FALSE,
|
||||
JsonNull.NULL, "JSON_STRING", "JSON_NUMBER"
|
||||
};
|
||||
throw unexpectedToken(token, expected);
|
||||
}
|
||||
|
||||
private @NotNull JsonParserException unexpectedEndOfFile() {
|
||||
return new JsonParserException(tokenizer.getLineNumber(), tokenizer.getColumnNumber(), "unexpected end of file");
|
||||
}
|
||||
|
||||
private @NotNull JsonParserException unexpectedToken(@Nullable JsonToken token, @NotNull Object @NotNull... expected) {
|
||||
if (expected.length == 1) {
|
||||
throw new JsonParserException(
|
||||
tokenizer.getLineNumber(), tokenizer.getColumnNumber(),
|
||||
"unexpected token: " + token + " (expected " + expected[0] + ")"
|
||||
);
|
||||
} else {
|
||||
throw new JsonParserException(
|
||||
tokenizer.getLineNumber(), tokenizer.getColumnNumber(),
|
||||
"unexpected token: " + token + " (expected one of " + Arrays.toString(expected) + ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an JSON object or array that is currently being parsed.
|
||||
*/
|
||||
private sealed interface Context {
|
||||
boolean isEmpty();
|
||||
|
||||
/**
|
||||
* Adds a nested JSON value to this context
|
||||
* @param value the value
|
||||
*/
|
||||
void add(@Nullable JsonValue value);
|
||||
|
||||
|
||||
/**
|
||||
* Builds and returns the JSON value represented by this context.
|
||||
* @return a JSON value
|
||||
*/
|
||||
@NotNull JsonValue build();
|
||||
|
||||
/**
|
||||
* {@return the token that indicates the end of this context}
|
||||
*/
|
||||
@NotNull JsonPunctuation end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON object that is currently being parsed.
|
||||
*/
|
||||
private static final class ObjectContext implements Context {
|
||||
private final @NotNull SequencedMap<@NotNull String, @Nullable JsonValue> map = new LinkedHashMap<>();
|
||||
|
||||
private @Nullable String key;
|
||||
|
||||
public void setKey(@Nullable String key) {
|
||||
if (this.key != null) throw new IllegalStateException();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(@Nullable JsonValue value) {
|
||||
if (key == null) throw new IllegalStateException();
|
||||
map.put(key, value);
|
||||
key = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return map.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull JsonValue build() {
|
||||
if (key != null) throw new IllegalStateException();
|
||||
return new JsonObject(map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull JsonPunctuation end() {
|
||||
return JsonPunctuation.END_OBJECT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON array that is currently being parsed.
|
||||
*/
|
||||
private static final class ArrayContext implements Context {
|
||||
private final @NotNull List<@Nullable JsonValue> list = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void add(@Nullable JsonValue value) {
|
||||
list.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return list.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull JsonArray build() {
|
||||
return new JsonArray(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull JsonPunctuation end() {
|
||||
return JsonPunctuation.END_ARRAY;
|
||||
}
|
||||
}
|
||||
}
|
@ -6,4 +6,5 @@ module eu.jonahbauer.json {
|
||||
exports eu.jonahbauer.json.exceptions;
|
||||
exports eu.jonahbauer.json.tokenizer;
|
||||
exports eu.jonahbauer.json.tokenizer.token;
|
||||
exports eu.jonahbauer.json.parser;
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
package eu.jonahbauer.json.parser;
|
||||
|
||||
import eu.jonahbauer.json.exceptions.JsonParserException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsonParserTest {
|
||||
|
||||
// https://github.com/nst/JSONTestSuite
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("parameters")
|
||||
void suite(@NotNull String name) throws IOException {
|
||||
var path = "/nst/JsonTestSuite/" + name;
|
||||
Boolean expected = switch (name.charAt(0)) {
|
||||
case 'i' -> null;
|
||||
case 'y' -> true;
|
||||
case 'n' -> false;
|
||||
default -> throw new IllegalArgumentException();
|
||||
};
|
||||
|
||||
try (
|
||||
var in = Objects.requireNonNull(JsonParserTest.class.getResourceAsStream(path));
|
||||
var reader = new InputStreamReader(in)
|
||||
) {
|
||||
var parser = new JsonParser(reader);
|
||||
|
||||
if (expected == Boolean.FALSE) {
|
||||
assertThrows(JsonParserException.class, parser::parse).printStackTrace(System.out);
|
||||
} else if (expected == Boolean.TRUE) {
|
||||
assertDoesNotThrow(parser::parse);
|
||||
} else {
|
||||
try {
|
||||
parser.parse();
|
||||
System.out.println("accepted");
|
||||
} catch (JsonParserException ex) {
|
||||
System.out.println("rejected");
|
||||
ex.printStackTrace(System.out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static @NotNull Stream<@NotNull Arguments> parameters() throws IOException {
|
||||
var filenames = new ArrayList<Arguments>();
|
||||
|
||||
try (
|
||||
InputStream in = Objects.requireNonNull(JsonParserTest.class.getResource("/nst/JsonTestSuite")).openStream();
|
||||
BufferedReader br = new BufferedReader(new InputStreamReader(in))
|
||||
) {
|
||||
String resource;
|
||||
while ((resource = br.readLine()) != null) {
|
||||
filenames.add(Arguments.of(resource));
|
||||
}
|
||||
}
|
||||
|
||||
return filenames.stream();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
[123.456e-789]
|
@ -0,0 +1 @@
|
||||
[0.4e00669999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999969999999006]
|
@ -0,0 +1 @@
|
||||
[-1e+9999]
|
@ -0,0 +1 @@
|
||||
[1.5e+9999]
|
@ -0,0 +1 @@
|
||||
[-123123e100000]
|
@ -0,0 +1 @@
|
||||
[123123e100000]
|
@ -0,0 +1 @@
|
||||
[123e-10000000]
|
@ -0,0 +1 @@
|
||||
[-123123123123123123123123123123]
|
@ -0,0 +1 @@
|
||||
[100000000000000000000]
|
@ -0,0 +1 @@
|
||||
[-237462374673276894279832749832423479823246327846]
|
@ -0,0 +1 @@
|
||||
{"\uDFAA":0}
|
@ -0,0 +1 @@
|
||||
["\uDADA"]
|
@ -0,0 +1 @@
|
||||
["\uD888\u1234"]
|
@ -0,0 +1 @@
|
||||
["é"]
|
@ -0,0 +1 @@
|
||||
["日ш<E697A5>"]
|
@ -0,0 +1 @@
|
||||
["<22>"]
|
@ -0,0 +1 @@
|
||||
["\uD800\n"]
|
@ -0,0 +1 @@
|
||||
["\uDd1ea"]
|
@ -0,0 +1 @@
|
||||
["\uD800\uD800\n"]
|
@ -0,0 +1 @@
|
||||
["\ud800"]
|
@ -0,0 +1 @@
|
||||
["\ud800abc"]
|
@ -0,0 +1 @@
|
||||
["<22>"]
|
@ -0,0 +1 @@
|
||||
["\uDd1e\uD834"]
|
@ -0,0 +1 @@
|
||||
["<22>"]
|
@ -0,0 +1 @@
|
||||
["\uDFAA"]
|
@ -0,0 +1 @@
|
||||
["<22>"]
|
@ -0,0 +1 @@
|
||||
["<22><><EFBFBD><EFBFBD>"]
|
@ -0,0 +1 @@
|
||||
["<22><>"]
|
@ -0,0 +1 @@
|
||||
["<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>"]
|
@ -0,0 +1 @@
|
||||
["<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>"]
|
@ -0,0 +1 @@
|
||||
["<22><>"]
|
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
|
||||
[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
|
@ -0,0 +1 @@
|
||||
{}
|
@ -0,0 +1 @@
|
||||
[1 true]
|
@ -0,0 +1 @@
|
||||
[a<EFBFBD>]
|
@ -0,0 +1 @@
|
||||
["": 1]
|
@ -0,0 +1 @@
|
||||
[""],
|
@ -0,0 +1 @@
|
||||
[,1]
|
@ -0,0 +1 @@
|
||||
[1,,2]
|
@ -0,0 +1 @@
|
||||
["x",,]
|
@ -0,0 +1 @@
|
||||
["x"]]
|
@ -0,0 +1 @@
|
||||
["",]
|
@ -0,0 +1 @@
|
||||
["x"
|
@ -0,0 +1 @@
|
||||
[x
|
@ -0,0 +1 @@
|
||||
[3[4]]
|
@ -0,0 +1 @@
|
||||
[<EFBFBD>]
|
@ -0,0 +1 @@
|
||||
[1:2]
|
@ -0,0 +1 @@
|
||||
[,]
|
@ -0,0 +1 @@
|
||||
[-]
|
@ -0,0 +1 @@
|
||||
[ , ""]
|
@ -0,0 +1,3 @@
|
||||
["a",
|
||||
4
|
||||
,1,
|
@ -0,0 +1 @@
|
||||
[1,]
|
@ -0,0 +1 @@
|
||||
[1,,]
|
@ -0,0 +1 @@
|
||||
["a"\f]
|
@ -0,0 +1 @@
|
||||
[*]
|
@ -0,0 +1 @@
|
||||
[""
|
@ -0,0 +1 @@
|
||||
[1,
|
@ -0,0 +1,3 @@
|
||||
[1,
|
||||
1
|
||||
,1
|
@ -0,0 +1 @@
|
||||
[{}
|
@ -0,0 +1 @@
|
||||
[fals]
|
@ -0,0 +1 @@
|
||||
[nul]
|
@ -0,0 +1 @@
|
||||
[tru]
|
Binary file not shown.
@ -0,0 +1 @@
|
||||
[++1234]
|
@ -0,0 +1 @@
|
||||
[+1]
|
@ -0,0 +1 @@
|
||||
[+Inf]
|
@ -0,0 +1 @@
|
||||
[-01]
|
@ -0,0 +1 @@
|
||||
[-1.0.]
|
@ -0,0 +1 @@
|
||||
[-2.]
|
@ -0,0 +1 @@
|
||||
[-NaN]
|
@ -0,0 +1 @@
|
||||
[.-1]
|
@ -0,0 +1 @@
|
||||
[.2e-3]
|
@ -0,0 +1 @@
|
||||
[0.1.2]
|
@ -0,0 +1 @@
|
||||
[0.3e+]
|
@ -0,0 +1 @@
|
||||
[0.3e]
|
@ -0,0 +1 @@
|
||||
[0.e1]
|
@ -0,0 +1 @@
|
||||
[0E+]
|
@ -0,0 +1 @@
|
||||
[0E]
|
@ -0,0 +1 @@
|
||||
[0e+]
|
@ -0,0 +1 @@
|
||||
[0e]
|
@ -0,0 +1 @@
|
||||
[1.0e+]
|
@ -0,0 +1 @@
|
||||
[1.0e-]
|
@ -0,0 +1 @@
|
||||
[1.0e]
|
@ -0,0 +1 @@
|
||||
[1 000.0]
|
@ -0,0 +1 @@
|
||||
[1eE2]
|
@ -0,0 +1 @@
|
||||
[2.e+3]
|
@ -0,0 +1 @@
|
||||
[2.e-3]
|
@ -0,0 +1 @@
|
||||
[2.e3]
|
@ -0,0 +1 @@
|
||||
[9.e+]
|
@ -0,0 +1 @@
|
||||
[Inf]
|
@ -0,0 +1 @@
|
||||
[NaN]
|
@ -0,0 +1 @@
|
||||
[1]
|
@ -0,0 +1 @@
|
||||
[1+2]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user