From 4d9a3ef4abd27d67a1fc8f2b9b636e6f8e449ae3 Mon Sep 17 00:00:00 2001 From: jbb01 <32650546+jbb01@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:53:18 +0200 Subject: [PATCH] add support for StringTemplates --- README.md | 25 +++ .../json/JsonTemplateProcessor.java | 37 ++++ .../java/eu/jonahbauer/json/JsonValue.java | 33 ++++ .../json/exceptions/JsonParserException.java | 8 +- .../json/exceptions/JsonReaderException.java | 13 +- .../exceptions/JsonTokenizerException.java | 8 +- .../eu/jonahbauer/json/parser/JsonParser.java | 59 +++++-- .../json/tokenizer/JsonTokenizer.java | 10 +- .../json/tokenizer/JsonTokenizerImpl.java | 36 +++- .../json/tokenizer/reader/PushbackReader.java | 18 +- .../tokenizer/reader/PushbackReaderImpl.java | 102 ++++++++++- .../json/tokenizer/token/JsonPlaceholder.java | 6 + .../tokenizer/token/JsonStringTemplate.java | 17 ++ .../json/tokenizer/token/JsonToken.java | 2 +- .../json/JsonTemplateProcessorTest.java | 159 ++++++++++++++++++ .../json/tokenizer/JsonTokenizerImplTest.java | 26 ++- 16 files changed, 512 insertions(+), 47 deletions(-) create mode 100644 core/src/main/java/eu/jonahbauer/json/JsonTemplateProcessor.java create mode 100644 core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonPlaceholder.java create mode 100644 core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonStringTemplate.java create mode 100644 core/src/test/java/eu/jonahbauer/json/JsonTemplateProcessorTest.java diff --git a/README.md b/README.md index 8f20f2c..2bd67dd 100644 --- a/README.md +++ b/README.md @@ -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!" +// } +``` \ No newline at end of file diff --git a/core/src/main/java/eu/jonahbauer/json/JsonTemplateProcessor.java b/core/src/main/java/eu/jonahbauer/json/JsonTemplateProcessor.java new file mode 100644 index 0000000..9875d32 --- /dev/null +++ b/core/src/main/java/eu/jonahbauer/json/JsonTemplateProcessor.java @@ -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 implements StringTemplate.Processor { + /** + * Parses the string template as a JSON using {@link JsonValue#parse(StringTemplate)}. + */ + public static final @NotNull JsonTemplateProcessor 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 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); + } +} diff --git a/core/src/main/java/eu/jonahbauer/json/JsonValue.java b/core/src/main/java/eu/jonahbauer/json/JsonValue.java index 587bfcb..4cc4e56 100644 --- a/core/src/main/java/eu/jonahbauer/json/JsonValue.java +++ b/core/src/main/java/eu/jonahbauer/json/JsonValue.java @@ -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 + * standard JSON grammar with the following additional rules: + *
{@code
+     * value
+     *    placeholder
+     *
+     * member
+     *    ws placeholder ws ':' element
+     *
+     * characters
+     *     placeholder characters
+     * }
+ * where placeholder represents an object embedded in the string template. + *

+ * 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: *

    diff --git a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonParserException.java b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonParserException.java index bcba689..434de60 100644 --- a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonParserException.java +++ b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonParserException.java @@ -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); } } diff --git a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonReaderException.java b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonReaderException.java index 12d1376..c70215f 100644 --- a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonReaderException.java +++ b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonReaderException.java @@ -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() { - return super.getMessage() + " at line " + getLineNumber() + ", column " + getColumnNumber(); + if (getFragment() == 0) { + return super.getMessage() + " at line " + getLineNumber() + ", column " + getColumnNumber(); + } else { + return super.getMessage() + " at fragment " + getFragment() + ", line " + getLineNumber() + ", column " + getColumnNumber(); + } } } diff --git a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonTokenizerException.java b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonTokenizerException.java index 847f60c..806b381 100644 --- a/core/src/main/java/eu/jonahbauer/json/exceptions/JsonTokenizerException.java +++ b/core/src/main/java/eu/jonahbauer/json/exceptions/JsonTokenizerException.java @@ -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); } } diff --git a/core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java b/core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java index dca9ced..50e58d5 100644 --- a/core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java +++ b/core/src/main/java/eu/jonahbauer/json/parser/JsonParser.java @@ -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) + ")" ); } diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizer.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizer.java index 7a878db..fcdd5e9 100644 --- a/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizer.java +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizer.java @@ -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 { */ @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 diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImpl.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImpl.java index 54c8eaf..49f2b86 100644 --- a/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImpl.java +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImpl.java @@ -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(); + var values = new ArrayList<>(); + var current = new StringBuilder(); while (true) { int chr = reader.read(); if (chr == '"') { - return new JsonString(current.toString()); + 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) { diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReader.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReader.java index 09a4eb1..8ff30d1 100644 --- a/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReader.java +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReader.java @@ -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; } diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReaderImpl.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReaderImpl.java index 71f26b3..8088292 100644 --- a/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReaderImpl.java +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/reader/PushbackReaderImpl.java @@ -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().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 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 values) implements AutoCloseable { + + @Override + public void close() throws ClosingException { + PushbackReaderImpl.close(readers); + } + } + + private static final class ClosingException extends IOException { } } diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonPlaceholder.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonPlaceholder.java new file mode 100644 index 0000000..871d5ed --- /dev/null +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonPlaceholder.java @@ -0,0 +1,6 @@ +package eu.jonahbauer.json.tokenizer.token; + +import org.jetbrains.annotations.Nullable; + +public record JsonPlaceholder(@Nullable Object object) implements JsonToken { +} diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonStringTemplate.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonStringTemplate.java new file mode 100644 index 0000000..a351ba4 --- /dev/null +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonStringTemplate.java @@ -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)); + } +} diff --git a/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonToken.java b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonToken.java index a8ec615..9592b1d 100644 --- a/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonToken.java +++ b/core/src/main/java/eu/jonahbauer/json/tokenizer/token/JsonToken.java @@ -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 { } diff --git a/core/src/test/java/eu/jonahbauer/json/JsonTemplateProcessorTest.java b/core/src/test/java/eu/jonahbauer/json/JsonTemplateProcessorTest.java new file mode 100644 index 0000000..ebc3797 --- /dev/null +++ b/core/src/test/java/eu/jonahbauer/json/JsonTemplateProcessorTest.java @@ -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(); + + 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(); + } +} diff --git a/core/src/test/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImplTest.java b/core/src/test/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImplTest.java index 6bfbdcd..24bf684 100644 --- a/core/src/test/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImplTest.java +++ b/core/src/test/java/eu/jonahbauer/json/tokenizer/JsonTokenizerImplTest.java @@ -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); + } }