add support for StringTemplates

This commit is contained in:
jbb01 2025-04-12 18:53:18 +02:00
parent 97892ff273
commit 4d9a3ef4ab
No known key found for this signature in database
GPG Key ID: 83C72CB6D5442CF1
16 changed files with 512 additions and 47 deletions

View File

@ -1,2 +1,27 @@
# json
A simple JSON library with support for `StringTemplate`s which were available in JDK 21 and 22 as a preview
feature and removed again in JDK 23. The library allows placeholders to be used as keys, values and inside strings.
### Example
```java
var parameter = "numbers";
var value = List.of(1, 2, 3, 4);
var name = "World";
var json = JSON_OBJECT."""
{
\{parameter}: \{value},
"string": "Hello \{name}!"
}
""";
assertEquals(3, json.getArray("numbers").getNumber(2));
assertEquals(JsonString.valueOf("Hello World!"), json.get("string"));
System.out.println(json.toPrettyJsonString());
// {
// "numbers": [1, 2, 3, 4],
// "string": "Hello World!"
// }
```

View File

@ -0,0 +1,37 @@
package eu.jonahbauer.json;
import eu.jonahbauer.json.exceptions.JsonConversionException;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("preview")
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class JsonTemplateProcessor<T extends JsonValue> implements StringTemplate.Processor<T, RuntimeException> {
/**
* Parses the string template as a JSON using {@link JsonValue#parse(StringTemplate)}.
*/
public static final @NotNull JsonTemplateProcessor<JsonValue> JSON = new JsonTemplateProcessor<>(JsonValue.class, true);
/**
* Parses the string template as a JSON using {@link JsonValue#parse(StringTemplate)}. Throws a
* {@link JsonConversionException} if the result is not a JSON object.
*/
public static final @NotNull JsonTemplateProcessor<@NotNull JsonObject> JSON_OBJECT = new JsonTemplateProcessor<>(JsonObject.class, false);
/**
* Parses the string template as a JSON using {@link JsonValue#parse(StringTemplate)}. Throws a
* {@link JsonConversionException} if the result is not a JSON array.
*/
public static final @NotNull JsonTemplateProcessor<@NotNull JsonArray> JSON_ARRAY = new JsonTemplateProcessor<>(JsonArray.class, false);
private final @NotNull Class<T> clazz;
private final boolean allowNull;
@Override
public T process(@NotNull StringTemplate template) throws RuntimeException {
var result = JsonValue.parse(template);
if (result == null && !allowNull || result != null && !clazz.isInstance(result)) {
throw new JsonConversionException("The template does not represent a " + clazz.getSimpleName());
}
return clazz.cast(result);
}
}

View File

@ -10,6 +10,7 @@ import org.jetbrains.annotations.Nullable;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public sealed interface JsonValue extends Serializable permits JsonObject, JsonArray, JsonBoolean, JsonNumber, JsonString {
@ -71,6 +72,38 @@ public sealed interface JsonValue extends Serializable permits JsonObject, JsonA
return parser.parse();
}
/**
* Parses a string template as a JSON. The string template must adhere to the
* <a href="https://www.json.org">standard JSON grammar</a> with the following additional rules:
* <pre>{@code
* value
* placeholder
*
* member
* ws placeholder ws ':' element
*
* characters
* placeholder characters
* }</pre>
* where <code>placeholder</code> represents an object embedded in the string template.
* <p>
* A placeholder will be converted to JSON depending on the context it appears in.
* When used as a value, {@link JsonValue#valueOf(Object)} will be used.
* When used as an object key, only instances or {@link CharSequence} are allowed, which will be converted using
* {@link CharSequence#toString()}.
* When used in a string, {@link Objects#toString(Object)} will be used.
*
* @param json a string template
* @return the result of parsing the string template as a JSON
* @throws JsonParserException if the string template cannot be parsed as JSON
* @throws JsonConversionException if a placeholder cannot be converted to JSON
*/
@SuppressWarnings("preview")
static @Nullable JsonValue parse(@NotNull StringTemplate json) {
var parser = new JsonParser(json);
return parser.parse();
}
/**
* Converts an object to a JSON value:
* <ul>

View File

@ -4,11 +4,11 @@ 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 fragment, int line, int column, @NotNull String message) {
super(fragment, line, column, message);
}
public JsonParserException(int line, int column, @NotNull String message, @NotNull Throwable cause) {
super(line, column, message, cause);
public JsonParserException(int fragment, int line, int column, @NotNull String message, @NotNull Throwable cause) {
super(fragment, line, column, message, cause);
}
}

View File

@ -5,23 +5,30 @@ import org.jetbrains.annotations.NotNull;
@Getter
abstract class JsonReaderException extends JsonProcessingException {
private final int fragment;
private final int lineNumber;
private final int columnNumber;
public JsonReaderException(int line, int column, @NotNull String message) {
public JsonReaderException(int fragment, int line, int column, @NotNull String message) {
super(message);
this.fragment = fragment;
this.lineNumber = line;
this.columnNumber = column;
}
public JsonReaderException(int line, int column, @NotNull String message, @NotNull Throwable cause) {
public JsonReaderException(int fragment, int line, int column, @NotNull String message, @NotNull Throwable cause) {
super(message, cause);
this.fragment = fragment;
this.lineNumber = line;
this.columnNumber = column;
}
@Override
public @NotNull String getMessage() {
if (getFragment() == 0) {
return super.getMessage() + " at line " + getLineNumber() + ", column " + getColumnNumber();
} else {
return super.getMessage() + " at fragment " + getFragment() + ", line " + getLineNumber() + ", column " + getColumnNumber();
}
}
}

View File

@ -4,11 +4,11 @@ import org.jetbrains.annotations.NotNull;
public class JsonTokenizerException extends JsonReaderException {
public JsonTokenizerException(int line, int column, @NotNull String message) {
super(line, column, message);
public JsonTokenizerException(int fragment, int line, int column, @NotNull String message) {
super(fragment, line, column, message);
}
public JsonTokenizerException(int line, int column, @NotNull String message, @NotNull Throwable cause) {
super(line, column, message, cause);
public JsonTokenizerException(int fragment, int line, int column, @NotNull String message, @NotNull Throwable cause) {
super(fragment, line, column, message, cause);
}
}

View File

@ -1,13 +1,12 @@
package eu.jonahbauer.json.parser;
import eu.jonahbauer.json.*;
import eu.jonahbauer.json.exceptions.JsonConversionException;
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 eu.jonahbauer.json.tokenizer.token.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -17,8 +16,18 @@ import java.io.UncheckedIOException;
import java.util.*;
public final class JsonParser {
private static final String TT_JSON_STRING = "JSON_STRING";
private static final String TT_JSON_NUMBER = "JSON_NUMBER";
private static final String TT_JSON_STRING_TEMPLATE = "JSON_STRING_TEMPLATE";
private static final String TT_JSON_PLACEHOLDER = "JSON_PLACEHOLDER";
private final @NotNull JsonTokenizer tokenizer;
@SuppressWarnings("preview")
public JsonParser(@NotNull StringTemplate json) {
this(new JsonTokenizerImpl(json));
}
public JsonParser(@NotNull String json) {
this(new JsonTokenizerImpl(json));
}
@ -41,13 +50,13 @@ public final class JsonParser {
try {
var out = parse0();
if (tokenizer.next() != null) {
throw new JsonParserException(tokenizer.getLineNumber(), tokenizer.getColumnNumber(), "unexpected trailing entries");
throw new JsonParserException(tokenizer.getFragment(), 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);
throw new JsonParserException(ex.getFragment(), ex.getLineNumber(), ex.getColumnNumber(), ex.getMessage(), ex);
}
}
@ -93,6 +102,8 @@ public final class JsonParser {
case JsonPunctuation.BEGIN_ARRAY, JsonPunctuation.BEGIN_OBJECT -> throw new AssertionError();
case JsonNull.NULL -> null;
case JsonValue v -> v;
case JsonStringTemplate template -> template.asString();
case JsonPlaceholder(var object) -> JsonValue.valueOf(object);
case JsonPunctuation _ -> throw unexpectedStartOfValue(token);
case null -> throw unexpectedEndOfFile();
};
@ -110,42 +121,62 @@ public final class JsonParser {
private @NotNull String parseJsonObjectKey(@NotNull ObjectContext context, @Nullable JsonToken token) {
return switch (token) {
case JsonString string -> string.value();
case JsonStringTemplate template -> template.asString().value();
case JsonPlaceholder(var object) -> switch (object) {
case CharSequence chars -> chars.toString();
case null -> throw new JsonConversionException("cannot use null as json object key");
default -> throw new JsonConversionException("cannot convert object of type " + object.getClass() + " to json object key");
};
case null -> throw unexpectedEndOfFile();
default -> throw unexpectedStartOfKey(context, token);
};
}
private @NotNull JsonParserException unexpectedStartOfKey(@NotNull ObjectContext context, @Nullable JsonToken token) {
var expected = new ArrayList<>(4);
expected.add(TT_JSON_STRING);
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");
expected.add(JsonPunctuation.END_OBJECT);
}
if (tokenizer.getFragment() != 0) {
// when the input is a StringTemplate, additional tokens are allowed
expected.add(TT_JSON_STRING_TEMPLATE);
expected.add(TT_JSON_PLACEHOLDER);
}
return unexpectedToken(token, expected.toArray());
}
private @NotNull JsonParserException unexpectedStartOfValue(@Nullable JsonToken token) {
var expected = new Object[] {
var expected = new ArrayList<>(9);
expected.addAll(Arrays.asList(
JsonPunctuation.BEGIN_OBJECT, JsonPunctuation.BEGIN_ARRAY, JsonBoolean.TRUE, JsonBoolean.FALSE,
JsonNull.NULL, "JSON_STRING", "JSON_NUMBER"
};
JsonNull.NULL, TT_JSON_STRING, TT_JSON_NUMBER
));
if (tokenizer.getFragment() != 0) {
expected.add(TT_JSON_STRING_TEMPLATE);
expected.add(TT_JSON_PLACEHOLDER);
}
throw unexpectedToken(token, expected);
}
private @NotNull JsonParserException unexpectedEndOfFile() {
return new JsonParserException(tokenizer.getLineNumber(), tokenizer.getColumnNumber(), "unexpected end of file");
return new JsonParserException(tokenizer.getFragment(), 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(),
tokenizer.getFragment(), tokenizer.getLineNumber(), tokenizer.getColumnNumber(),
"unexpected token: " + token + " (expected " + expected[0] + ")"
);
} else {
throw new JsonParserException(
tokenizer.getLineNumber(), tokenizer.getColumnNumber(),
tokenizer.getFragment(), tokenizer.getLineNumber(), tokenizer.getColumnNumber(),
"unexpected token: " + token + " (expected one of " + Arrays.toString(expected) + ")"
);
}

View File

@ -3,6 +3,7 @@ package eu.jonahbauer.json.tokenizer;
import eu.jonahbauer.json.tokenizer.token.JsonToken;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -19,15 +20,20 @@ public interface JsonTokenizer extends Iterable<JsonToken> {
*/
@Nullable JsonToken next() throws IOException;
/**
* {@return the fragment at which the previously read token appeared}
*/
@Range(from = 0, to = Integer.MAX_VALUE) int getFragment();
/**
* {@return the line at which the previously read token appeared}
*/
int getLineNumber();
@Range(from = 1, to = Integer.MAX_VALUE) int getLineNumber();
/**
* {@return the column at which the previously read token appeared}
*/
int getColumnNumber();
@Range(from = 1, to = Integer.MAX_VALUE) int getColumnNumber();
/**
* {@return an iterator over the tokens} The {@link Iterator#next()} and {@link Iterator#hasNext()} methods

View File

@ -3,9 +3,7 @@ package eu.jonahbauer.json.tokenizer;
import eu.jonahbauer.json.exceptions.JsonTokenizerException;
import eu.jonahbauer.json.tokenizer.reader.PushbackReader;
import eu.jonahbauer.json.tokenizer.reader.PushbackReaderImpl;
import eu.jonahbauer.json.tokenizer.token.JsonNull;
import eu.jonahbauer.json.tokenizer.token.JsonPunctuation;
import eu.jonahbauer.json.tokenizer.token.JsonToken;
import eu.jonahbauer.json.tokenizer.token.*;
import lombok.Getter;
import eu.jonahbauer.json.JsonBoolean;
import eu.jonahbauer.json.JsonNumber;
@ -14,6 +12,7 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.util.ArrayList;
import java.util.regex.Pattern;
public final class JsonTokenizerImpl implements JsonTokenizer {
@ -30,6 +29,8 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
private final @NotNull PushbackReader reader;
private final int[] buffer = new int[4];
@Getter
private int fragment;
@Getter
private int lineNumber;
@Getter
@ -39,6 +40,11 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
this(new StringReader(json));
}
@SuppressWarnings("preview")
public JsonTokenizerImpl(@NotNull StringTemplate template) {
this.reader = new PushbackReaderImpl(template);
}
public JsonTokenizerImpl(@NotNull Reader reader) {
this.reader = new PushbackReaderImpl(reader);
}
@ -51,10 +57,12 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
chr = reader.read();
} while (isWhitespace(chr));
fragment = reader.getFragment();
lineNumber = reader.getLineNumber();
columnNumber = reader.getColumnNumber();
if (chr == PushbackReader.EOF) return null;
if (chr == PushbackReader.PLACEHOLDER) return new JsonPlaceholder(reader.getObject());
if (chr < 128 && PUNCTUATION[chr] != null) return PUNCTUATION[chr];
if (chr == '"') return nextString();
@ -102,12 +110,22 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
}
}
private @NotNull JsonString nextString() throws IOException {
private @NotNull JsonToken nextString() throws IOException {
var fragments = new ArrayList<String>();
var values = new ArrayList<>();
var current = new StringBuilder();
while (true) {
int chr = reader.read();
if (chr == '"') {
if (fragments.isEmpty()) {
return new JsonString(current.toString());
} else {
fragments.add(current.toString());
@SuppressWarnings("preview")
var result = new JsonStringTemplate(StringTemplate.of(fragments, values));
return result;
}
} else if (chr == '\\') {
chr = reader.read();
switch (chr) {
@ -151,6 +169,10 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
} else if (chr == PushbackReader.EOF) {
throw error("unclosed string literal");
} else if (chr == PushbackReader.PLACEHOLDER) {
fragments.add(current.toString());
current.setLength(0);
values.add(reader.getObject());
} else if (chr < 32) {
throw error("unescaped control character in string literal");
} else {
@ -160,11 +182,11 @@ public final class JsonTokenizerImpl implements JsonTokenizer {
}
private @NotNull JsonTokenizerException error(@NotNull String message) {
return new JsonTokenizerException(getLineNumber(), getColumnNumber(), message);
return new JsonTokenizerException(getFragment(), getLineNumber(), getColumnNumber(), message);
}
private @NotNull JsonTokenizerException error(@NotNull String message, @NotNull Throwable cause) {
return new JsonTokenizerException(getLineNumber(), getColumnNumber(), message, cause);
return new JsonTokenizerException(getFragment(), getLineNumber(), getColumnNumber(), message, cause);
}
private static @NotNull String toString(int chr) {

View File

@ -1,18 +1,20 @@
package eu.jonahbauer.json.tokenizer.reader;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;
import java.io.IOException;
public interface PushbackReader extends AutoCloseable {
int EOF = -1;
int PLACEHOLDER = -2;
/**
* Reads the next character.
* @return the next character or {@link #EOF} if the end of the stream has been reached
* @throws IOException if an I/O error occurs
*/
@Range(from = -1, to = Character.MAX_VALUE) int read() throws IOException;
@Range(from = -2, to = Character.MAX_VALUE) int read() throws IOException;
/**
* Pushes the reader back, making {@link #read()} return the same character as before.
@ -20,6 +22,13 @@ public interface PushbackReader extends AutoCloseable {
*/
void pushback();
/**
* {@return the index of the fragment the current character belongs to} Returns 0 unless a {@code StringTemplate} is
* being read.
* @throws IllegalStateException when called before the first read
*/
@Range(from = 0, to = Integer.MAX_VALUE) int getFragment();
/**
* {@return the line number of the current character}
* @throws IllegalStateException when called before the first read
@ -32,6 +41,13 @@ public interface PushbackReader extends AutoCloseable {
*/
@Range(from = 1, to = Integer.MAX_VALUE) int getColumnNumber();
/**
* Return the object that has been read after {@link #read()} returned {@link #PLACEHOLDER}.
* @throws IllegalStateException the previous call to read did not return {@link #PLACEHOLDER}
* @return the object that has been read
*/
@Nullable Object getObject();
@Override
void close() throws IOException;
}

View File

@ -1,22 +1,46 @@
package eu.jonahbauer.json.tokenizer.reader;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.Reader;
import java.util.Objects;
import java.io.StringReader;
import java.util.*;
public class PushbackReaderImpl implements PushbackReader {
private final @NotNull Reader reader;
private @Nullable Input input;
private @Nullable Reader reader;
private int current = Integer.MIN_VALUE;
private int lineNumber = 1;
private int columnNumber = 0; // column number will be incremented when a character is read
private Object object;
private int fragment;
private int lineNumber;
private int columnNumber;
private boolean pushback;
public PushbackReaderImpl(@NotNull Reader reader) {
this.reader = Objects.requireNonNull(reader, "reader");
this.input = new Input(List.of(reader).iterator(), Collections.emptyIterator());
this.fragment = -1; // fragment will be incremented to 0 when the first character is read
}
@SuppressWarnings("preview")
public PushbackReaderImpl(@NotNull StringTemplate template) {
this.input = new Input(
template.fragments().stream().<Reader>map(StringReader::new).iterator(),
template.values().iterator()
);
this.fragment = 0; // fragment will be incremented to 1 when the first character is read
}
@Override
public int getFragment() {
if (current == Integer.MIN_VALUE) {
throw new IllegalStateException("No character has been read so far.");
}
return fragment;
}
@Override
@ -52,6 +76,21 @@ public class PushbackReaderImpl implements PushbackReader {
return current;
}
var input = this.input;
if (input == null) {
throw new IOException("closed");
}
var reader = this.reader;
if (reader == null && input.readers().hasNext()) {
this.reader = reader = input.readers().next();
fragment++;
lineNumber = 1;
columnNumber = 0;
} else if (reader == null) {
return EOF;
}
int result = reader.read();
if (current == '\n' && result != '\n' || current == '\r' && result != '\n' && result != '\r') {
@ -61,12 +100,59 @@ public class PushbackReaderImpl implements PushbackReader {
columnNumber++;
}
current = result;
return result;
if (result == EOF) {
reader.close();
this.reader = null;
if (input.values().hasNext()) {
this.object = input.values().next();
return this.current = PLACEHOLDER;
}
}
this.object = null;
return this.current = result;
}
@Override
public @Nullable Object getObject() {
if (current != PLACEHOLDER) {
throw new IllegalStateException("Currently not at a placeholder.");
}
return object;
}
@Override
public void close() throws IOException {
reader.close();
var input = this.input;
var reader = this.reader;
this.input = null;
this.reader = null;
close(Arrays.asList(input, reader).iterator());
}
private static void close(@NotNull Iterator<? extends @Nullable AutoCloseable> closeables) throws ClosingException {
ClosingException exception = null;
while (closeables.hasNext()) {
var closeable = closeables.next();
if (closeable == null) continue;
try {
closeable.close();
} catch (Throwable t) {
if (exception == null) exception = new ClosingException();
exception.addSuppressed(t);
}
}
if (exception != null) throw exception;
}
private record Input(@NotNull Iterator<@NotNull Reader> readers, @NotNull Iterator<Object> values) implements AutoCloseable {
@Override
public void close() throws ClosingException {
PushbackReaderImpl.close(readers);
}
}
private static final class ClosingException extends IOException { }
}

View File

@ -0,0 +1,6 @@
package eu.jonahbauer.json.tokenizer.token;
import org.jetbrains.annotations.Nullable;
public record JsonPlaceholder(@Nullable Object object) implements JsonToken {
}

View File

@ -0,0 +1,17 @@
package eu.jonahbauer.json.tokenizer.token;
import eu.jonahbauer.json.JsonString;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
@SuppressWarnings("preview")
public record JsonStringTemplate(@NotNull StringTemplate template) implements JsonToken {
public JsonStringTemplate {
Objects.requireNonNull(template);
}
public @NotNull JsonString asString() {
return new JsonString(STR.process(template));
}
}

View File

@ -7,4 +7,4 @@ import eu.jonahbauer.json.JsonString;
/**
* Represents a JSON token.
*/
public sealed interface JsonToken permits JsonBoolean, JsonNull, JsonNumber, JsonString, JsonPunctuation { }
public sealed interface JsonToken permits JsonBoolean, JsonNumber, JsonString, JsonNull, JsonPlaceholder, JsonPunctuation, JsonStringTemplate { }

View File

@ -0,0 +1,159 @@
package eu.jonahbauer.json;
import eu.jonahbauer.json.exceptions.JsonParserException;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
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.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Stream;
import static eu.jonahbauer.json.JsonTemplateProcessor.*;
import static org.junit.jupiter.api.Assertions.*;
@SuppressWarnings("preview")
class JsonTemplateProcessorTest {
@Test
void processWithoutPlaceholders() {
JsonValue value = JSON."""
{
"string": "string",
"number": 1337,
"true": true,
"false": false,
"null": null,
"array": [1, "2", true, false, null]
}
""";
assertInstanceOf(JsonObject.class, value);
JsonObject object = (JsonObject) value;
assertEquals(JsonValue.valueOf("string"), object.get("string"));
assertEquals(JsonValue.valueOf(1337), object.get("number"));
assertEquals(JsonValue.valueOf(true), object.get("true"));
assertEquals(JsonValue.valueOf(false), object.get("false"));
assertEquals(JsonValue.valueOf(null), object.get("null"));
assertEquals(JsonValue.valueOf(Arrays.asList(1, "2", true, false, null)), object.get("array"));
}
@Test
void processWithValuePlaceholder() {
JsonValue value = JSON."""
{
"string": \{"string"},
"number": \{1337},
"true": \{true},
"false": \{false},
"null": \{null},
"array": \{Arrays.asList(1, "2", true, false, null)}
}
""";
assertInstanceOf(JsonObject.class, value);
JsonObject object = (JsonObject) value;
assertEquals(JsonValue.valueOf("string"), object.get("string"));
assertEquals(JsonValue.valueOf(1337), object.get("number"));
assertEquals(JsonValue.valueOf(true), object.get("true"));
assertEquals(JsonValue.valueOf(false), object.get("false"));
assertEquals(JsonValue.valueOf(null), object.get("null"));
assertEquals(JsonValue.valueOf(Arrays.asList(1, "2", true, false, null)), object.get("array"));
}
@Test
void processWithKeyPlaceholder() {
String key = "foo";
JsonValue value = JSON."""
{
\{key}: "value"
}
""";
assertInstanceOf(JsonObject.class, value);
JsonObject object = (JsonObject) value;
assertEquals(JsonValue.valueOf("value"), object.get(key));
}
@Test
void processWithPlaceholderInInvalidPosition() {
assertThrows(JsonParserException.class, () -> {
var _ = JSON."{\"key\" \{"value"}}";
}).printStackTrace(System.out);
}
@Test
void processWithPlaceholderInStringLiteral() {
Object object = new Object();
JsonValue value = JSON."{\"key\": \"Hello \{object}!\"}";
assertEquals(JsonValue.valueOf(Map.of(
"key", "Hello " + object + "!"
)), value);
}
@Test
void processWithPlaceholderInKeyStringLiteral() {
Object object = new Object();
JsonValue value = JSON."{\"key-\{object}\": \"value\"}";
assertEquals(JsonValue.valueOf(Map.of(
"key-" + object, "value"
)), value);
}
@ParameterizedTest(name = "{0}")
@MethodSource("parameters")
void suite(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(JsonTemplateProcessor.class.getResourceAsStream(path))) {
String input = new String(in.readAllBytes(), StandardCharsets.UTF_8);
if (expected == Boolean.FALSE) {
assertThrows(JsonParserException.class, () -> JSON.process(StringTemplate.of(input))).printStackTrace(System.out);
} else if (expected == Boolean.TRUE) {
Assertions.assertDoesNotThrow(() -> JSON.process(StringTemplate.of(input)));
} else {
try {
JSON.process(StringTemplate.of(input));
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(JsonTemplateProcessorTest.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();
}
}

View File

@ -4,14 +4,13 @@ import eu.jonahbauer.json.JsonBoolean;
import eu.jonahbauer.json.JsonNumber;
import eu.jonahbauer.json.JsonString;
import eu.jonahbauer.json.exceptions.JsonTokenizerException;
import eu.jonahbauer.json.tokenizer.token.JsonNull;
import eu.jonahbauer.json.tokenizer.token.JsonPunctuation;
import eu.jonahbauer.json.tokenizer.token.JsonToken;
import eu.jonahbauer.json.tokenizer.token.*;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Test;
import java.util.List;
import static java.lang.StringTemplate.RAW;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -115,9 +114,30 @@ class JsonTokenizerImplTest {
test("1,234", new JsonNumber(1), JsonPunctuation.VALUE_SEPARATOR, new JsonNumber(234));
}
@Test
@SuppressWarnings("preview")
void stringTemplate() {
var name = "World";
test(RAW."\"Hello, \{name}!\"", new JsonStringTemplate(RAW."Hello, \{name}!"));
}
@Test
@SuppressWarnings("preview")
void placeholder() {
var name = "World";
test(RAW."\{name}", new JsonPlaceholder(name));
}
private void test(@NotNull String json, @NotNull JsonToken @NotNull... expected) {
var tokenizer = new JsonTokenizerImpl(json);
var actual = tokenizer.stream().toList();
assertEquals(List.of(expected), actual);
}
@SuppressWarnings("preview")
private void test(@NotNull StringTemplate json, @NotNull JsonToken @NotNull... expected) {
var tokenizer = new JsonTokenizerImpl(json);
var actual = tokenizer.stream().toList();
assertEquals(List.of(expected), actual);
}
}