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..ba92be5 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; @@ -43,9 +45,14 @@ public final class SNBTParser { private @Nullable Token lookahead; 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 +81,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 +117,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 +159,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; }; @@ -307,8 +322,12 @@ T nextToken(Class expected) { } Token peekToken() { + return peekToken(false); + } + + Token peekToken(boolean tokenizeNewLine) { if (lookahead == null) { - lookahead = readNextToken(); + lookahead = readNextToken(tokenizeNewLine); } return lookahead; } @@ -400,8 +419,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 +451,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..2e66c69 100644 --- a/src/main/java/org/glavo/nbt/io/SNBTCodec.java +++ b/src/main/java/org/glavo/nbt/io/SNBTCodec.java @@ -110,14 +110,29 @@ 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) { + this( + lineBreakStrategy, + indentation, + surroundingSpaces, + escapeStrategy, + nameQuoteStrategy, + valueQuoteStrategy, + false); + } - 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. @@ -245,6 +260,16 @@ public SNBTCodec withValueQuoteStrategy(QuoteStrategy quoteStrategy) { return new SNBTCodec(lineBreakStrategy, indentation, surroundingSpaces, escapeStrategy, nameQuoteStrategy, quoteStrategy); } + @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. /// /// @throws IOException if the input is not a valid Stringified NBT data. @@ -261,7 +286,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); } 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..61caae3 --- /dev/null +++ b/src/test/java/org/glavo/nbt/io/SNBTCodecTest.java @@ -0,0 +1,62 @@ +/* + * 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")); + var list = assertInstanceOf(ListTag.class, compound.get("list")); + assert list != null : "ensured by assertInstanceOf"; + assertEquals(TagType.INT, list.getElementType()); + var expected = new ListTag<>(TagType.INT); + expected.setName("list"); + expected.addTags(new IntTag(1), new IntTag(2), new IntTag(3), new IntTag(4)); + Assertions.assertEquals(expected, list); + } + } + +} 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..92f286e --- /dev/null +++ b/src/test/resources/assets/nbt/newline_separated.snbt @@ -0,0 +1,9 @@ +{ + name: "Foo" + list: [ + 1 + 2 + 3 + 4 + ] +} \ No newline at end of file