diff --git a/src/main/java/org/glavo/nbt/internal/snbt/SNBTParser.java b/src/main/java/org/glavo/nbt/internal/snbt/SNBTParser.java index 5cf2fb5..4fb91fb 100644 --- a/src/main/java/org/glavo/nbt/internal/snbt/SNBTParser.java +++ b/src/main/java/org/glavo/nbt/internal/snbt/SNBTParser.java @@ -32,6 +32,8 @@ public final class SNBTParser { private final CharSequence input; private final int endIndex; + /// Whether allows New Line (\n) as separators in Compound and List. Used in FTB-flavored SNBT. + private final boolean allowNewLineAsSeparator; private boolean parsingPath = false; @@ -41,11 +43,22 @@ public final class SNBTParser { private @Nullable StringBuilder buffer; private @Nullable Token lookahead; + /// the tokenizeNewLine flag of the corresponding lookahead value, used to check if a re-read is needed. + /// the nullability is same to the lookahead, which when lookahead is null, this is null. + private @Nullable Boolean lookaheadNewLineParam; + /// the cursor to roll back to when the lookahead need to be re-read. + /// the nullability is same to the lookahead, which when lookahead is null, this is null. + private @Nullable Integer lookaheadCursor; public SNBTParser(CharSequence input, int beginIndex, int endIndex) { + this(input, beginIndex, endIndex, false); + } + + public SNBTParser(CharSequence input, int beginIndex, int endIndex, boolean allowNewLineAsSeparator) { Objects.checkFromToIndex(beginIndex, endIndex, input.length()); this.input = input; this.endIndex = endIndex; + this.allowNewLineAsSeparator = allowNewLineAsSeparator; this.cursor = beginIndex; } @@ -74,9 +87,12 @@ private StringBuilder getBuilder() { return buffer; } - private void skipWhiteSpace() { + private void skipWhiteSpace(boolean keepNewLine) { while (cursor < endIndex) { int ch = getCodePoint(); + if (allowNewLineAsSeparator && keepNewLine && ch == '\n') { + break; + } if (Character.isWhitespace(ch)) { cursor += Character.charCount(ch); } else { @@ -107,7 +123,11 @@ private boolean isUnquotedStringPart(int ch) { } Token readNextToken() { - skipWhiteSpace(); + return readNextToken(false); + } + + Token readNextToken(boolean tokenizeNewLine) { + skipWhiteSpace(tokenizeNewLine); if (cursor >= endIndex) { return Token.SimpleToken.EOF; @@ -145,6 +165,7 @@ Token readNextToken() { case '.' -> cursor >= endIndex || !TextUtils.isAsciiDigit(input.charAt(cursor)) ? Token.SimpleToken.DOT : null; // Floating point number + case '\n' -> Token.SimpleToken.NEW_LINE; // '\n' will be skipped above unless allowed default -> null; }; @@ -285,6 +306,8 @@ Token nextToken() { if (lookahead != null) { Token token = lookahead; lookahead = null; + lookaheadNewLineParam = null; + lookaheadCursor = null; return token; } return readNextToken(); @@ -307,8 +330,24 @@ T nextToken(Class expected) { } Token peekToken() { - if (lookahead == null) { - lookahead = readNextToken(); + return peekToken(false); + } + + Token peekToken(boolean tokenizeNewLine) { + // nullability of lookaheadNewLineParam is same to lookahead. + assert (lookahead != null) == (lookaheadNewLineParam != null); + assert (lookahead != null) == (lookaheadCursor != null); + + if(lookahead == null) { + // preserved the rollback point + lookaheadCursor = cursor; + lookahead = readNextToken(tokenizeNewLine); + lookaheadNewLineParam = tokenizeNewLine; + } else if(lookaheadNewLineParam != tokenizeNewLine) { + // the flag doesn't match, do the rollback + cursor = lookaheadCursor; + lookahead = readNextToken(tokenizeNewLine); + lookaheadNewLineParam = tokenizeNewLine; } return lookahead; } @@ -318,6 +357,8 @@ void discardPeekedToken(Token token) { throw new AssertionError("Expected " + token + ", but got " + lookahead); } lookahead = null; + lookaheadNewLineParam = null; + lookaheadCursor = null; } public @Nullable Tag nextTag() throws IllegalArgumentException { @@ -400,8 +441,8 @@ private CompoundTag nextCompoundTag(boolean shareEmpty) throws IllegalArgumentEx tag.addTag(nameToken.value(), value); - Token peek = peekToken(); - if (peek == Token.SimpleToken.COMMA) { + Token peek = peekToken(true); + if (peek == Token.SimpleToken.COMMA || peek == Token.SimpleToken.NEW_LINE) { discardPeekedToken(peek); } else if (peek == Token.SimpleToken.RIGHT_BRACE) { discardPeekedToken(peek); @@ -432,8 +473,8 @@ private ListTag nextListTag() throws IllegalArgumentException { } tag.addAnyTag(value); - peek = peekToken(); - if (peek == Token.SimpleToken.COMMA) { + peek = peekToken(true); + if (peek == Token.SimpleToken.COMMA || peek == Token.SimpleToken.NEW_LINE) { discardPeekedToken(peek); } else if (peek == Token.SimpleToken.RIGHT_BRACKET) { discardPeekedToken(peek); diff --git a/src/main/java/org/glavo/nbt/internal/snbt/Token.java b/src/main/java/org/glavo/nbt/internal/snbt/Token.java index da904bf..5272835 100644 --- a/src/main/java/org/glavo/nbt/internal/snbt/Token.java +++ b/src/main/java/org/glavo/nbt/internal/snbt/Token.java @@ -34,6 +34,7 @@ enum SimpleToken implements Token { COMMA, // , COLON, // : DOT, // . + NEW_LINE, // \n EOF } diff --git a/src/main/java/org/glavo/nbt/io/SNBTCodec.java b/src/main/java/org/glavo/nbt/io/SNBTCodec.java index d3726b9..5440eca 100644 --- a/src/main/java/org/glavo/nbt/io/SNBTCodec.java +++ b/src/main/java/org/glavo/nbt/io/SNBTCodec.java @@ -83,8 +83,8 @@ public final class SNBTCodec { SurroundingSpaces.COMPACT, EscapeStrategy.defaultStrategy(), QuoteStrategy.defaultNameStrategy(), - QuoteStrategy.defaultValueStrategy() - ); + QuoteStrategy.defaultValueStrategy(), + false); private static final SNBTCodec PRETTY = new SNBTCodec( LineBreakStrategy.defaultStrategy(), @@ -92,8 +92,8 @@ public final class SNBTCodec { SurroundingSpaces.PRETTY, EscapeStrategy.defaultStrategy(), QuoteStrategy.defaultNameStrategy(), - QuoteStrategy.defaultValueStrategy() - ); + QuoteStrategy.defaultValueStrategy(), + false); public static SNBTCodec of() { return PRETTY; @@ -110,14 +110,17 @@ public static SNBTCodec ofCompact() { private final EscapeStrategy escapeStrategy; private final QuoteStrategy nameQuoteStrategy; private final QuoteStrategy valueQuoteStrategy; + private final boolean allowNewLineAsSeparator; - private SNBTCodec(LineBreakStrategy lineBreakStrategy, String indentation, SurroundingSpaces surroundingSpaces, EscapeStrategy escapeStrategy, QuoteStrategy nameQuoteStrategy, QuoteStrategy valueQuoteStrategy) { + private SNBTCodec(LineBreakStrategy lineBreakStrategy, String indentation, SurroundingSpaces surroundingSpaces, + EscapeStrategy escapeStrategy, QuoteStrategy nameQuoteStrategy, QuoteStrategy valueQuoteStrategy, boolean allowNewLineAsSeparator) { this.lineBreakStrategy = lineBreakStrategy; this.indentation = indentation; this.surroundingSpaces = surroundingSpaces; this.escapeStrategy = escapeStrategy; this.nameQuoteStrategy = nameQuoteStrategy; this.valueQuoteStrategy = valueQuoteStrategy; + this.allowNewLineAsSeparator = allowNewLineAsSeparator; } /// Returns the line break strategy for all parent tags. @@ -136,7 +139,7 @@ public LineBreakStrategy getLineBreakStrategy() { @Contract(value = "_ -> new", pure = true) public SNBTCodec withLineBreakStrategy(LineBreakStrategy strategy) { Objects.requireNonNull(strategy, "strategy"); - return new SNBTCodec(strategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return new SNBTCodec(strategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Returns the indentation string before each line. @@ -160,7 +163,7 @@ public SNBTCodec withIndentation(String indentation) { } } - return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } @@ -172,7 +175,7 @@ public SNBTCodec withIndentation(String indentation) { /// @see #withIndentation(String) @Contract(value = "_ -> new", pure = true) public SNBTCodec withIndentation(int spaces) { - return new SNBTCodec(lineBreakStrategy, " ".repeat(spaces), surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return new SNBTCodec(lineBreakStrategy, " ".repeat(spaces), surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Returns the surrounding spaces for SNBT. @@ -185,7 +188,7 @@ public SurroundingSpaces getSurroundingSpaces() { @Contract(value = "_ -> new", pure = true) public SNBTCodec withSurroundingSpaces(SurroundingSpaces surroundingSpaces) { Objects.requireNonNull(surroundingSpaces, "surroundingSpaces"); - return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Returns the escape strategy for SNBT. @@ -204,7 +207,7 @@ public EscapeStrategy getEscapeStrategy() { @Contract(value = "_ -> new", pure = true) public SNBTCodec withEscapeStrategy(EscapeStrategy escapeStrategy) { Objects.requireNonNull(escapeStrategy, "escapeStrategy"); - return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Returns the quote strategy for SNBT tag names. @@ -223,7 +226,7 @@ public QuoteStrategy getNameQuoteStrategy() { @Contract(value = "_ -> new", pure = true) public SNBTCodec withNameQuoteStrategy(QuoteStrategy quoteStrategy) { Objects.requireNonNull(quoteStrategy, "quoteStrategy"); - return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, quoteStrategy, valueQuoteStrategy); + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, quoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Returns the quote strategy for SNBT tag values. @@ -242,7 +245,17 @@ public QuoteStrategy getValueQuoteStrategy() { @Contract(value = "_ -> new", pure = true) public SNBTCodec withValueQuoteStrategy(QuoteStrategy quoteStrategy) { Objects.requireNonNull(quoteStrategy, "quoteStrategy"); - return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, quoteStrategy); + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, quoteStrategy, allowNewLineAsSeparator); + } + + @Contract(pure = true) + public boolean getAllowNewLineAsSeparator() { + return allowNewLineAsSeparator; + } + + @Contract(pure = true) + public SNBTCodec withAllowNewLineAsSeparator(boolean allowNewLineAsSeparator) { + return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } /// Reads a NBT tag from the Stringified NBT data. @@ -261,7 +274,7 @@ public Tag readTag(CharSequence input) throws IOException { public Tag readTag(CharSequence input, int startInclusive, int endExclusive) throws IOException { Tag tag; try { - tag = new SNBTParser(input, startInclusive, endExclusive).nextTag(); + tag = new SNBTParser(input, startInclusive, endExclusive, allowNewLineAsSeparator).nextTag(); } catch (IllegalArgumentException e) { throw new IOException(e); } @@ -324,12 +337,13 @@ public boolean equals(Object o) { && this.surroundingSpaces.equals(that.surroundingSpaces) && this.escapeStrategy.equals(that.escapeStrategy) && this.nameQuoteStrategy.equals(that.nameQuoteStrategy) - && this.valueQuoteStrategy.equals(that.valueQuoteStrategy); + && this.valueQuoteStrategy.equals(that.valueQuoteStrategy) + && this.allowNewLineAsSeparator == that.allowNewLineAsSeparator; } @Override public int hashCode() { - return Objects.hash(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy); + return Objects.hash(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, valueQuoteStrategy, allowNewLineAsSeparator); } @Override diff --git a/src/test/java/org/glavo/nbt/io/SNBTCodecTest.java b/src/test/java/org/glavo/nbt/io/SNBTCodecTest.java new file mode 100644 index 0000000..ab79a3a --- /dev/null +++ b/src/test/java/org/glavo/nbt/io/SNBTCodecTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Taskeren + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.glavo.nbt.io; + +import org.glavo.nbt.TestResources; +import org.glavo.nbt.tag.CompoundTag; +import org.glavo.nbt.tag.IntTag; +import org.glavo.nbt.tag.ListTag; +import org.glavo.nbt.tag.TagType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public final class SNBTCodecTest { + + @Test + void testNewLineSeparatedSNBT() throws IOException { + Path resource = TestResources.getResource("/assets/nbt/newline_separated.snbt"); + // expected failure + // the vanilla-flavored SNBT should not split compounds and lists with '\n'. + Assertions.assertThrows( + IOException.class, () -> { + try (var reader = Files.newBufferedReader(resource, StandardCharsets.UTF_8)) { + SNBTCodec.of().readTag(reader); + } + }); + + try (var reader = Files.newBufferedReader(resource, StandardCharsets.UTF_8)) { + var tag = SNBTCodec.of().withAllowNewLineAsSeparator(true).readTag(reader); + var compound = assertInstanceOf(CompoundTag.class, tag); + assertEquals("Foo", compound.getString("name")); + // check list + var list = assertInstanceOf(ListTag.class, compound.get("list")); + assert list != null : "ensured by assertInstanceOf"; + assertEquals(TagType.INT, list.getElementType()); + assertEquals(new ListTag<>(TagType.INT).setName("list") + .addTag(new IntTag(1)) + .addTag(new IntTag(2)) + .addTag(new IntTag(3)) + .addTag(new IntTag(4)), list); + // check compound + var strDict = assertInstanceOf(CompoundTag.class, compound.get("str_dict")); + assertEquals(new CompoundTag() + .setName("str_dict") + .setString("a", "foo") + .setString("b", "bar") + .setString("c", "baz") + .setString("d", "Hello\nWorld") + .setIntArray("e", new int[] {-886784530, 1488270925, -1459824571, -372109282}) + .setBoolean("f", true), strDict); + } + } + +} diff --git a/src/test/resources/assets/nbt/newline_separated.snbt b/src/test/resources/assets/nbt/newline_separated.snbt new file mode 100644 index 0000000..eb5f6f0 --- /dev/null +++ b/src/test/resources/assets/nbt/newline_separated.snbt @@ -0,0 +1,18 @@ +{ + name: "Foo" + list: [ + 1 + 2 + 3 + 4 + ] + str_dict: { + a: foo + b: bar + c: baz + d: "Hello +World" + e: uuid(cb24bdee-58b5-364d-a8fc-d845e9d2101e) + f: bool(1) + } +} \ No newline at end of file