diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index fc76626..90c3169 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -12,11 +12,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 16 - uses: actions/setup-java@v2 + - uses: actions/checkout@v3 + - name: Set up JDK 18 + uses: actions/setup-java@v3 with: - java-version: 16 + java-version: 18 distribution: 'temurin' cache: gradle - name: Build with Gradle diff --git a/README.md b/README.md index 641c044..c865744 100644 --- a/README.md +++ b/README.md @@ -108,16 +108,11 @@ value : STRING|NUMBER|object|array|TRUE|FALSE|NULL ## TODO List -* Error handling - * Line + character count for where bad characters found - * Better messages for why parsing fails * Performance - * Continue to improve Tokenizer#tokenString method + * Research improvements to Tokenizer#tokenString method * Research improvements to Tokenizer#tokenNumber method - * Improve Parser performance + * Research improving Parser performance * Research alternative ways of getting initial character array for Tokenizer (maybe not using String#toCharArray()) * API * Pretty serializing of JsonElement, JsonObject and JsonArray * JavaDoc all the things -* Bugs - * JsonElement#toString (and SolaJson#serialize) doesn't support unicode characters diff --git a/build.gradle.kts b/build.gradle.kts index d57f15a..41f2cf9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,11 +2,12 @@ plugins { id("java-library") } -version = "1.0.2" +version = "2.0.0" java { - sourceCompatibility = JavaVersion.VERSION_16 - targetCompatibility = JavaVersion.VERSION_16 + toolchain { + languageVersion.set(JavaLanguageVersion.of(18)) + } } repositories { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 69a9715..aa991fc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/technology/sola/json/JsonElement.java b/src/main/java/technology/sola/json/JsonElement.java index af211a3..8a2e504 100644 --- a/src/main/java/technology/sola/json/JsonElement.java +++ b/src/main/java/technology/sola/json/JsonElement.java @@ -110,7 +110,12 @@ private static String escapeNonUnicode(String s){ .replace("\n", "\\n") .replace("\r", "\\r") .replace("\f", "\\f") - .replace("\"", "\\\""); + .replace("\"", "\\\"") + // line separator + .replace("\u2028", "\\u2028") + // paragraph separator + .replace("\u2029", "\\u2029") + ; } private void assertType(JsonValueType assertionType) { diff --git a/src/main/java/technology/sola/json/JsonMapper.java b/src/main/java/technology/sola/json/JsonMapper.java index 4feccdc..be0bf46 100644 --- a/src/main/java/technology/sola/json/JsonMapper.java +++ b/src/main/java/technology/sola/json/JsonMapper.java @@ -2,6 +2,11 @@ import java.util.List; +/** + * JsonMapper defines how a class will be converted to and from {@link JsonObject}s. + * + * @param the type to define a JSON mapping for + */ public interface JsonMapper { /** * Converts object of type T into a {@link JsonObject}. diff --git a/src/main/java/technology/sola/json/exception/InvalidCharacterException.java b/src/main/java/technology/sola/json/exception/InvalidCharacterException.java index fe63a4a..45d5b28 100644 --- a/src/main/java/technology/sola/json/exception/InvalidCharacterException.java +++ b/src/main/java/technology/sola/json/exception/InvalidCharacterException.java @@ -1,7 +1,21 @@ package technology.sola.json.exception; public class InvalidCharacterException extends RuntimeException { - public InvalidCharacterException(char invalidCharacter) { - super(String.format("Invalid character [%s]", invalidCharacter)); + private final char invalidCharacter; + private final int startIndex; + + public InvalidCharacterException(char invalidCharacter, int startIndex) { + super(String.format("Invalid character [%s] at [%s]", invalidCharacter, startIndex)); + + this.invalidCharacter = invalidCharacter; + this.startIndex = startIndex; + } + + public char getInvalidCharacter() { + return invalidCharacter; + } + + public int getStartIndex() { + return startIndex; } } diff --git a/src/main/java/technology/sola/json/exception/InvalidControlCharacterException.java b/src/main/java/technology/sola/json/exception/InvalidControlCharacterException.java index c48e5f5..95a8dab 100644 --- a/src/main/java/technology/sola/json/exception/InvalidControlCharacterException.java +++ b/src/main/java/technology/sola/json/exception/InvalidControlCharacterException.java @@ -1,4 +1,14 @@ package technology.sola.json.exception; public class InvalidControlCharacterException extends RuntimeException { + private final int startIndex; + + public InvalidControlCharacterException(int startIndex) { + super("Invalid control character at [" + startIndex + "]"); + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } } diff --git a/src/main/java/technology/sola/json/exception/InvalidDecimalNumberException.java b/src/main/java/technology/sola/json/exception/InvalidDecimalNumberException.java new file mode 100644 index 0000000..ded300e --- /dev/null +++ b/src/main/java/technology/sola/json/exception/InvalidDecimalNumberException.java @@ -0,0 +1,14 @@ +package technology.sola.json.exception; + +public class InvalidDecimalNumberException extends RuntimeException { + private final int startIndex; + + public InvalidDecimalNumberException(int startIndex) { + super("Number for decimal expected starting at [" + startIndex + "]"); + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } +} diff --git a/src/main/java/technology/sola/json/exception/InvalidKeywordException.java b/src/main/java/technology/sola/json/exception/InvalidKeywordException.java index 8ca65c1..486199e 100644 --- a/src/main/java/technology/sola/json/exception/InvalidKeywordException.java +++ b/src/main/java/technology/sola/json/exception/InvalidKeywordException.java @@ -1,7 +1,26 @@ package technology.sola.json.exception; public class InvalidKeywordException extends RuntimeException { - public InvalidKeywordException(String keyword, String current, char invalidChar) { - super("Expected keyword [" + keyword + "] but have [" + current + invalidChar + "]"); + private final String expectedKeyword; + private final String receivedKeyword; + private final int startIndex; + + public InvalidKeywordException(String keyword, String current, char invalidChar, int startIndex) { + super("Expected keyword [" + keyword + "] but received [" + current + invalidChar + "] at [" + startIndex + "]"); + this.expectedKeyword = keyword; + this.receivedKeyword = current + invalidChar; + this.startIndex = startIndex; + } + + public String getExpectedKeyword() { + return expectedKeyword; + } + + public String getReceivedKeyword() { + return receivedKeyword; + } + + public int getStartIndex() { + return startIndex; } } diff --git a/src/main/java/technology/sola/json/exception/InvalidNegativeNumberException.java b/src/main/java/technology/sola/json/exception/InvalidNegativeNumberException.java new file mode 100644 index 0000000..5392c34 --- /dev/null +++ b/src/main/java/technology/sola/json/exception/InvalidNegativeNumberException.java @@ -0,0 +1,14 @@ +package technology.sola.json.exception; + +public class InvalidNegativeNumberException extends RuntimeException { + private final int startIndex; + + public InvalidNegativeNumberException(int startIndex) { + super("Negative number expected following '-' at [" + startIndex + "]"); + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } +} diff --git a/src/main/java/technology/sola/json/exception/InvalidNumberException.java b/src/main/java/technology/sola/json/exception/InvalidNumberException.java deleted file mode 100644 index 7038166..0000000 --- a/src/main/java/technology/sola/json/exception/InvalidNumberException.java +++ /dev/null @@ -1,4 +0,0 @@ -package technology.sola.json.exception; - -public class InvalidNumberException extends RuntimeException { -} diff --git a/src/main/java/technology/sola/json/exception/InvalidSyntaxException.java b/src/main/java/technology/sola/json/exception/InvalidSyntaxException.java index 996ef92..8bc880e 100644 --- a/src/main/java/technology/sola/json/exception/InvalidSyntaxException.java +++ b/src/main/java/technology/sola/json/exception/InvalidSyntaxException.java @@ -1,4 +1,12 @@ package technology.sola.json.exception; +import technology.sola.json.token.TokenType; + +import java.util.Arrays; +import java.util.stream.Collectors; + public class InvalidSyntaxException extends RuntimeException { + public InvalidSyntaxException(TokenType actual, int index, TokenType ...expected) { + super("Expected [" + Arrays.stream(expected).map(TokenType::name).collect(Collectors.joining(" or ")) + "] but received [" + actual.name() + "] at [" + index + "]"); + } } diff --git a/src/main/java/technology/sola/json/exception/InvalidUnicodeCharacterException.java b/src/main/java/technology/sola/json/exception/InvalidUnicodeCharacterException.java new file mode 100644 index 0000000..8cda21b --- /dev/null +++ b/src/main/java/technology/sola/json/exception/InvalidUnicodeCharacterException.java @@ -0,0 +1,14 @@ +package technology.sola.json.exception; + +public class InvalidUnicodeCharacterException extends RuntimeException { + private final int startIndex; + + public InvalidUnicodeCharacterException(int startIndex) { + super("Invalid unicode character must be 4 numbers in length at [" + startIndex + "]"); + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } +} diff --git a/src/main/java/technology/sola/json/exception/StringNotClosedException.java b/src/main/java/technology/sola/json/exception/StringNotClosedException.java index a0d391b..5fb2421 100644 --- a/src/main/java/technology/sola/json/exception/StringNotClosedException.java +++ b/src/main/java/technology/sola/json/exception/StringNotClosedException.java @@ -1,4 +1,15 @@ package technology.sola.json.exception; public class StringNotClosedException extends RuntimeException { + private final int startIndex; + + public StringNotClosedException(int startIndex) { + super("String starting at [" + startIndex + "] not closed"); + + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } } diff --git a/src/main/java/technology/sola/json/parser/Parser.java b/src/main/java/technology/sola/json/parser/Parser.java index ce7438d..4ad5e20 100644 --- a/src/main/java/technology/sola/json/parser/Parser.java +++ b/src/main/java/technology/sola/json/parser/Parser.java @@ -21,7 +21,7 @@ public AstNode parse() { AstNode node = ruleRoot(); if (currentToken.type() != TokenType.EOF) { - throw new InvalidSyntaxException(); + throw new InvalidSyntaxException(currentToken.type(), tokenizer.getTextIndex(), TokenType.EOF); } return node; @@ -31,7 +31,7 @@ private AstNode ruleRoot() { return switch (currentToken.type()) { case L_BRACKET -> ruleArray(); case L_CURLY -> ruleObject(); - default -> throw new InvalidSyntaxException(); + default -> throw new InvalidSyntaxException(currentToken.type(), tokenizer.getTextIndex(), TokenType.L_BRACKET, TokenType.L_CURLY); }; } @@ -103,7 +103,10 @@ private AstNode ruleValue() { eat(TokenType.NUMBER); yield AstNode.value(token); } - default -> throw new RuntimeException("Unrecognized value type " + token.type()); + default -> throw new InvalidSyntaxException( + token.type(), tokenizer.getTextIndex(), + TokenType.L_BRACKET, TokenType.L_CURLY, TokenType.TRUE, TokenType.FALSE, TokenType.NULL, TokenType.STRING, TokenType.NUMBER + ); }; } @@ -111,7 +114,7 @@ private void eat(TokenType tokenType) { if (currentToken.type() == tokenType) { currentToken = tokenizer.getNextToken(); } else { - throw new InvalidSyntaxException(); + throw new InvalidSyntaxException(currentToken.type(), tokenizer.getTextIndex(), tokenType); } } } diff --git a/src/main/java/technology/sola/json/token/Tokenizer.java b/src/main/java/technology/sola/json/token/Tokenizer.java index 191f93b..c8226d2 100644 --- a/src/main/java/technology/sola/json/token/Tokenizer.java +++ b/src/main/java/technology/sola/json/token/Tokenizer.java @@ -12,6 +12,10 @@ public Tokenizer(String text) { currentChar = characters[textIndex]; } + public int getTextIndex() { + return textIndex; + } + public Token getNextToken() { if (currentChar == null) { return new Token(TokenType.EOF); @@ -75,7 +79,7 @@ public Token getNextToken() { return new Token(TokenType.NULL); } - throw new InvalidCharacterException(currentChar); + throw new InvalidCharacterException(currentChar, textIndex); } private Token tokenString() { @@ -102,7 +106,7 @@ private Token tokenString() { localPos++; if (localPos >= buffer.length) { - throw new StringNotClosedException(); + throw new StringNotClosedException(start); } localChar = buffer[localPos]; } @@ -128,41 +132,44 @@ private Token tokenNumber() { int characterCount = textIndex - startIndex; if (characterCount == 1 && characters[startIndex] == '-') { - throw new InvalidNumberException(); + throw new InvalidNegativeNumberException(startIndex); } return new Token(TokenType.NUMBER, new String(characters, startIndex, characterCount)); } private void advanceKeywordTrue() { + int keywordStartIndex = textIndex; advance(); - if (currentChar != 'r') throw new InvalidKeywordException("true", "t", currentChar); + if (currentChar != 'r') throw new InvalidKeywordException("true", "t", currentChar, keywordStartIndex); advance(); - if (currentChar != 'u') throw new InvalidKeywordException("true", "tr", currentChar); + if (currentChar != 'u') throw new InvalidKeywordException("true", "tr", currentChar, keywordStartIndex); advance(); - if (currentChar != 'e') throw new InvalidKeywordException("true", "tru", currentChar); + if (currentChar != 'e') throw new InvalidKeywordException("true", "tru", currentChar, keywordStartIndex); advance(); } private void advanceKeywordNull() { + int keywordStartIndex = textIndex; advance(); - if (currentChar != 'u') throw new InvalidKeywordException("null", "n", currentChar); + if (currentChar != 'u') throw new InvalidKeywordException("null", "n", currentChar, keywordStartIndex); advance(); - if (currentChar != 'l') throw new InvalidKeywordException("null", "nu", currentChar); + if (currentChar != 'l') throw new InvalidKeywordException("null", "nu", currentChar, keywordStartIndex); advance(); - if (currentChar != 'l') throw new InvalidKeywordException("null", "nul", currentChar); + if (currentChar != 'l') throw new InvalidKeywordException("null", "nul", currentChar, keywordStartIndex); advance(); } private void advanceKeywordFalse() { + int keywordStartIndex = textIndex; advance(); - if (currentChar != 'a') throw new InvalidKeywordException("false", "f", currentChar); + if (currentChar != 'a') throw new InvalidKeywordException("false", "f", currentChar, keywordStartIndex); advance(); - if (currentChar != 'l') throw new InvalidKeywordException("false", "fa", currentChar); + if (currentChar != 'l') throw new InvalidKeywordException("false", "fa", currentChar, keywordStartIndex); advance(); - if (currentChar != 's') throw new InvalidKeywordException("false", "fal", currentChar); + if (currentChar != 's') throw new InvalidKeywordException("false", "fal", currentChar, keywordStartIndex); advance(); - if (currentChar != 'e') throw new InvalidKeywordException("false", "fals", currentChar); + if (currentChar != 'e') throw new InvalidKeywordException("false", "fals", currentChar, keywordStartIndex); advance(); } @@ -183,7 +190,7 @@ private int advanceEscapeCharacter(char[] buffer, int pos, StringBuilder stringB localPos++; if (localPos + 4 > buffer.length) { - throw new InvalidControlCharacterException(); + throw new InvalidUnicodeCharacterException(localPos); } try { @@ -191,10 +198,10 @@ private int advanceEscapeCharacter(char[] buffer, int pos, StringBuilder stringB localPos += 3; yield (char) codePoint; } catch (NumberFormatException ex) { - throw new InvalidControlCharacterException(); + throw new InvalidUnicodeCharacterException(localPos); } } - default -> throw new InvalidControlCharacterException(); + default -> throw new InvalidControlCharacterException(localPos); }; stringBuilder.append(result); @@ -219,7 +226,7 @@ private void advanceFraction() { } } if (textIndex - startFraction == 1) { - throw new InvalidNumberException(); + throw new InvalidDecimalNumberException(startFraction); } } diff --git a/src/test/java/technology/sola/json/token/TokenizerTest.java b/src/test/java/technology/sola/json/token/TokenizerTest.java index fb6a52a..de9dee4 100644 --- a/src/test/java/technology/sola/json/token/TokenizerTest.java +++ b/src/test/java/technology/sola/json/token/TokenizerTest.java @@ -2,9 +2,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import technology.sola.json.exception.InvalidControlCharacterException; -import technology.sola.json.exception.InvalidNumberException; -import technology.sola.json.exception.StringNotClosedException; +import technology.sola.json.exception.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -12,6 +10,16 @@ class TokenizerTest { @Nested class getNextToken { + @Test + void whenInvalidCharacter_shouldThrowException() { + var input = " invalid "; + + InvalidCharacterException exception = assertThrows(InvalidCharacterException.class, () -> new Tokenizer(input).getNextToken()); + + assertEquals('i', exception.getInvalidCharacter()); + assertEquals(1, exception.getStartIndex()); + } + @Test void shouldEndWithEof() { var input = " "; @@ -65,34 +73,67 @@ void shouldRecognizeBracket() { .assertNextToken(TokenType.EOF); } - @Test - void shouldRecognizeTrue() { - var input = " true true "; + @Nested + class keyword { + @Test + void shouldRecognizeTrue() { + var input = " true true "; - createTest(input) - .assertNextToken(TokenType.TRUE) - .assertNextToken(TokenType.TRUE) - .assertNextToken(TokenType.EOF); - } + createTest(input) + .assertNextToken(TokenType.TRUE) + .assertNextToken(TokenType.TRUE) + .assertNextToken(TokenType.EOF); + } - @Test - void shouldRecognizeFalse() { - var input = " false false "; + @Test + void whenInvalidTrue_shouldThrowException() { + var input = " tru "; - createTest(input) - .assertNextToken(TokenType.FALSE) - .assertNextToken(TokenType.FALSE) - .assertNextToken(TokenType.EOF); - } + InvalidKeywordException exception = assertThrows(InvalidKeywordException.class, () -> new Tokenizer(input).getNextToken()); + assertEquals("true", exception.getExpectedKeyword()); + assertEquals("tru ", exception.getReceivedKeyword()); + assertEquals(1, exception.getStartIndex()); + } - @Test - void shouldRecognizeNull() { - var input = " null null "; + @Test + void shouldRecognizeFalse() { + var input = " false false "; - createTest(input) - .assertNextToken(TokenType.NULL) - .assertNextToken(TokenType.NULL) - .assertNextToken(TokenType.EOF); + createTest(input) + .assertNextToken(TokenType.FALSE) + .assertNextToken(TokenType.FALSE) + .assertNextToken(TokenType.EOF); + } + + @Test + void whenInvalidFalse_shouldThrowException() { + var input = " fals "; + + InvalidKeywordException exception = assertThrows(InvalidKeywordException.class, () -> new Tokenizer(input).getNextToken()); + assertEquals("false", exception.getExpectedKeyword()); + assertEquals("fals ", exception.getReceivedKeyword()); + assertEquals(1, exception.getStartIndex()); + } + + @Test + void shouldRecognizeNull() { + var input = " null null "; + + createTest(input) + .assertNextToken(TokenType.NULL) + .assertNextToken(TokenType.NULL) + .assertNextToken(TokenType.EOF); + } + + @Test + void whenInvalidNull_shouldThrowException() { + var input = " nul "; + + InvalidKeywordException exception = assertThrows(InvalidKeywordException.class, () -> new Tokenizer(input).getNextToken()); + assertEquals("null", exception.getExpectedKeyword()); + assertEquals("nul ", exception.getReceivedKeyword()); + assertEquals(1, exception.getStartIndex()); + } } @Nested @@ -111,7 +152,9 @@ void whenValid_shouldRecognize() { void whenNotClosed_shouldThrowException() { var input = " \"test "; - assertThrows(StringNotClosedException.class, () -> createTest(input).assertNextToken(TokenType.STRING)); + StringNotClosedException exception = assertThrows(StringNotClosedException.class, () -> createTest(input).assertNextToken(TokenType.STRING)); + + assertEquals(2, exception.getStartIndex()); } @Nested @@ -119,28 +162,40 @@ class withControlCharacters { @Test void whenControlCharacterNotFinished_shouldThrowException() { var input = """ - " \\ " - """; - - assertThrows(InvalidControlCharacterException.class, () -> createTest(input).assertNextToken(TokenType.STRING)); + " \\ " + """; + + InvalidControlCharacterException exception = assertThrows( + InvalidControlCharacterException.class, + () -> createTest(input).assertNextToken(TokenType.STRING) + ); + assertEquals(3, exception.getStartIndex()); } @Test void whenInvalidUnicode_shouldThrowException() { var input = """ - "\\u12r3" - """; - - assertThrows(InvalidControlCharacterException.class, () -> createTest(input).assertNextToken(TokenType.STRING)); + "\\u12r3" + """; + + InvalidUnicodeCharacterException exception = assertThrows( + InvalidUnicodeCharacterException.class, + () -> createTest(input).assertNextToken(TokenType.STRING) + ); + assertEquals(3, exception.getStartIndex()); } @Test void whenIncompleteUnicode_shouldThrowException() { var input = """ - "\\u12" - """; - - assertThrows(InvalidControlCharacterException.class, () -> createTest(input).assertNextToken(TokenType.STRING)); + "\\u12" + """; + + InvalidUnicodeCharacterException exception = assertThrows( + InvalidUnicodeCharacterException.class, + () -> createTest(input).assertNextToken(TokenType.STRING) + ); + assertEquals(3, exception.getStartIndex()); } @Test @@ -156,9 +211,9 @@ void whenEscapedQuote_shouldRecognize() { @Test void whenNonUnicodeControlCharacter_shouldRecognize() { var input = """ - "\\" \\/ \\\\ \\b \\f \\n \\r \\t" - "\\" \\/ \\\\ \\b \\f \\n \\r \\t" - """; + "\\" \\/ \\\\ \\b \\f \\n \\r \\t" + "\\" \\/ \\\\ \\b \\f \\n \\r \\t" + """; createTest(input) .assertNextToken(TokenType.STRING, "\" / \\ \b \f \n \r \t") @@ -169,9 +224,9 @@ void whenNonUnicodeControlCharacter_shouldRecognize() { @Test void whenUnicode_shouldRecognize() { var input = """ - "\\u1234 \\uabcd \\u0000 \\uffff" - "\\u1234 \\uabcd \\u0000 \\uffff" - """; + "\\u1234 \\uabcd \\u0000 \\uffff" + "\\u1234 \\uabcd \\u0000 \\uffff" + """; createTest(input) .assertNextToken(TokenType.STRING, "\u1234 \uabcd \u0000 \uffff") @@ -187,14 +242,22 @@ class number { void whenOnlyMinus_ShouldThrowException() { var input = " - "; - assertThrows(InvalidNumberException.class, () -> createTest(input).assertNextToken(TokenType.NUMBER)); + InvalidNegativeNumberException exception = assertThrows( + InvalidNegativeNumberException.class, + () -> createTest(input).assertNextToken(TokenType.NUMBER) + ); + assertEquals(1, exception.getStartIndex()); } @Test void whenDotWithNoFraction_ShouldThrowException() { var input = " 2. "; - assertThrows(InvalidNumberException.class, () -> createTest(input).assertNextToken(TokenType.NUMBER)); + InvalidDecimalNumberException exception = assertThrows( + InvalidDecimalNumberException.class, + () -> createTest(input).assertNextToken(TokenType.NUMBER) + ); + assertEquals(2, exception.getStartIndex()); } @Test @@ -258,7 +321,7 @@ private TokenizerTester createTest(String input) { return new TokenizerTester(tokenizer); } - private static record TokenizerTester(Tokenizer tokenizer) { + private record TokenizerTester(Tokenizer tokenizer) { TokenizerTester assertNextToken(TokenType expectedType) { assertNextToken(expectedType, null);