Skip to content

Commit 1998c5b

Browse files
authored
Merge pull request #23 from EngineHub/ot/feature/add-nbtops-heterogenous-compat
Support heterogenous lists in LinOps
2 parents 378024b + 4d85653 commit 1998c5b

2 files changed

Lines changed: 152 additions & 40 deletions

File tree

dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
import org.enginehub.linbus.tree.LinStringTag;
4040
import org.enginehub.linbus.tree.LinTag;
4141
import org.enginehub.linbus.tree.LinTagType;
42+
import org.jspecify.annotations.Nullable;
4243

4344
import java.nio.ByteBuffer;
4445
import java.nio.IntBuffer;
4546
import java.nio.LongBuffer;
4647
import java.util.ArrayList;
4748
import java.util.Iterator;
4849
import java.util.List;
50+
import java.util.Map;
4951
import java.util.Map.Entry;
5052
import java.util.function.BiConsumer;
5153
import java.util.function.Consumer;
@@ -57,7 +59,7 @@
5759
* A {@link DynamicOps} implementation backed by lin-bus tags, analogous to Minecraft's {@code NbtOps}.
5860
*
5961
* <p>
60-
* The lin-bus tags are stricter than generic NBT: lists are homogeneous and compounds cannot contain an END value.
62+
* The lin-bus tags are stricter than generic NBT as compounds cannot contain an END value.
6163
* Operations that would produce such a structure either return a failed {@link DataResult} or throw.
6264
* </p>
6365
*/
@@ -189,39 +191,21 @@ public DataResult<LinTag<?>> mergeToList(LinTag<?> list, LinTag<?> value) {
189191
@Override
190192
public DataResult<LinTag<?>> mergeToList(LinTag<?> list, List<LinTag<?>> values) {
191193
return switch (list) {
192-
case LinListTag<?> existing -> mergeList(existing.value(), values);
194+
case LinListTag<?> existing -> mergeRaw(unwrapStoredList(existing), values);
193195
case LinByteArrayTag array when array.view().hasRemaining() -> mergeBytes(array, values);
194196
case LinIntArrayTag array when array.view().hasRemaining() -> mergeInts(array, values);
195197
case LinLongArrayTag array when array.view().hasRemaining() -> mergeLongs(array, values);
196-
case LinByteArrayTag _, LinIntArrayTag _, LinLongArrayTag _, LinEndTag _ -> mergeList(List.of(), values);
198+
case LinByteArrayTag _, LinIntArrayTag _, LinLongArrayTag _, LinEndTag _ -> mergeRaw(List.of(), values);
197199
default -> DataResult.error(() -> "mergeToList called with non-list: " + list, list);
198200
};
199201
}
200202

201-
private static DataResult<LinTag<?>> mergeList(List<? extends LinTag<?>> prefix, List<LinTag<?>> values) {
203+
private static DataResult<LinTag<?>> mergeRaw(List<LinTag<?>> prefix, List<LinTag<?>> values) {
202204
if (prefix.isEmpty() && values.isEmpty()) {
203205
return DataResult.success(LinListTag.empty(LinTagType.endTag()));
204206
}
205-
LinTagType<? extends LinTag<?>> elementType =
206-
prefix.isEmpty() ? values.getFirst().type() : prefix.getFirst().type();
207-
return mergeListTyped(elementType, prefix, values);
208-
}
209-
210-
@SuppressWarnings("unchecked")
211-
private static <T extends LinTag<?>> DataResult<LinTag<?>> mergeListTyped(
212-
LinTagType<T> elementType, List<? extends LinTag<?>> prefix, List<LinTag<?>> values
213-
) {
214-
LinListTag.Builder<T> builder = LinListTag.builderWithExpectedSize(
215-
elementType, prefix.size() + values.size()
216-
);
217207
try {
218-
/*
219-
* addAll checks every element against elementType and throws on a mismatch, so the
220-
* casts to the element type are sound.
221-
*/
222-
builder.addAll((List<? extends T>) prefix);
223-
builder.addAll((List<? extends T>) values);
224-
return DataResult.success(builder.build());
208+
return DataResult.success(createHomogenizedList(prefix, values));
225209
} catch (IllegalArgumentException e) {
226210
return DataResult.error(e::getMessage);
227211
}
@@ -373,7 +357,7 @@ public LinTag<?> createMap(Stream<Pair<LinTag<?>, LinTag<?>>> map) {
373357
@Override
374358
public DataResult<Stream<LinTag<?>>> getStream(LinTag<?> input) {
375359
return switch (input) {
376-
case LinListTag<?> tag -> DataResult.success(tag.value().stream().map(element -> (LinTag<?>) element));
360+
case LinListTag<?> tag -> DataResult.success(unwrapStoredList(tag).stream());
377361
case LinByteArrayTag tag -> {
378362
ByteBuffer values = tag.view();
379363
yield DataResult.success(
@@ -442,22 +426,102 @@ public LinTag<?> createLongList(LongStream input) {
442426

443427
@Override
444428
public LinTag<?> createList(Stream<LinTag<?>> input) {
445-
List<LinTag<?>> elements = input.toList();
446-
if (elements.isEmpty()) {
429+
return createHomogenizedList(input.toList(), List.of());
430+
}
431+
432+
private static LinTag<?> createHomogenizedList(List<LinTag<?>> prefix, List<LinTag<?>> values) {
433+
LinTagType<? extends LinTag<?>> rawType = identifyRawElementType(prefix, values);
434+
if (rawType == LinTagType.endTag()) {
435+
assert prefix.isEmpty() && values.isEmpty()
436+
: "rawType is endTag but elements are not empty: " + prefix + ", " + values;
447437
return LinListTag.empty(LinTagType.endTag());
448438
}
449-
return createListTagTyped(elements.getFirst().type(), elements);
439+
boolean compound = rawType == LinTagType.compoundTag();
440+
LinListTag.Builder<LinTag<?>> builder = homogenizedBuilder(rawType, prefix.size() + values.size());
441+
addHomogenized(builder, prefix, compound);
442+
addHomogenized(builder, values, compound);
443+
return builder.build();
450444
}
451445

452446
@SuppressWarnings("unchecked")
453-
private static <T extends LinTag<?>> LinListTag<T> createListTagTyped(
454-
LinTagType<T> elementType, List<LinTag<?>> elements
447+
private static LinListTag.Builder<LinTag<?>> homogenizedBuilder(
448+
LinTagType<? extends LinTag<?>> elementType, int expectedSize
455449
) {
456450
/*
457-
* LinListTag.of checks every element against elementType, which is the runtime type
458-
* of the first element, so casting the homogeneous list to List<T> is sound.
451+
* The builder checks every element against elementType, which is the shared runtime type of the
452+
* elements, so widening the builder's element type to LinTag<?> is sound.
459453
*/
460-
return LinListTag.of(elementType, (List<T>) elements);
454+
return (LinListTag.Builder<LinTag<?>>) LinListTag.builderWithExpectedSize(elementType, expectedSize);
455+
}
456+
457+
private static void addHomogenized(
458+
LinListTag.Builder<LinTag<?>> builder, List<LinTag<?>> elements, boolean compound
459+
) {
460+
if (!compound) {
461+
builder.addAll(elements);
462+
return;
463+
}
464+
for (LinTag<?> element : elements) {
465+
builder.add(element instanceof LinCompoundTag existing ? existing : wrapElement(element));
466+
}
467+
}
468+
469+
/**
470+
* {@return the shared type of {@code elements}, or the compound type if they are heterogeneous}
471+
*
472+
* <p>
473+
* This mirrors Minecraft's {@code ListTag} raw-element-type identification: a heterogeneous list is
474+
* reported as compound so its elements are stored in wrapper compounds.
475+
* </p>
476+
*/
477+
private static LinTagType<? extends LinTag<?>> identifyRawElementType(
478+
List<LinTag<?>> prefix, List<LinTag<?>> values
479+
) {
480+
LinTagType<? extends LinTag<?>> type = scanRawElementType(null, prefix);
481+
if (type != LinTagType.compoundTag()) {
482+
type = scanRawElementType(type, values);
483+
}
484+
return type == null ? LinTagType.endTag() : type;
485+
}
486+
487+
private static @Nullable LinTagType<? extends LinTag<?>> scanRawElementType(
488+
@Nullable LinTagType<? extends LinTag<?>> currentType, List<LinTag<?>> elements
489+
) {
490+
for (LinTag<?> element : elements) {
491+
LinTagType<? extends LinTag<?>> elementType = element.type();
492+
// If it's a compound list or heterogeneous, we treat it as a compound list.
493+
if (elementType == LinTagType.compoundTag() || (currentType != null && currentType != elementType)) {
494+
return LinTagType.compoundTag();
495+
}
496+
currentType = elementType;
497+
}
498+
return currentType;
499+
}
500+
501+
/**
502+
* {@return the raw (logical) elements of a stored list, unwrapping any {@code {"": value}} wrappers}
503+
*/
504+
private static List<LinTag<?>> unwrapStoredList(LinListTag<?> list) {
505+
boolean wrapped = list.elementType() == LinTagType.compoundTag();
506+
List<LinTag<?>> result = new ArrayList<>(list.value().size());
507+
for (LinTag<?> element : list.value()) {
508+
result.add(wrapped ? tryUnwrap(element) : element);
509+
}
510+
return result;
511+
}
512+
513+
private static LinCompoundTag wrapElement(LinTag<?> element) {
514+
return LinCompoundTag.of(Map.of("", element));
515+
}
516+
517+
private static LinTag<?> tryUnwrap(LinTag<?> element) {
518+
if (element instanceof LinCompoundTag compound && compound.value().size() == 1) {
519+
LinTag<?> unwrapped = compound.value().get("");
520+
if (unwrapped != null) {
521+
return unwrapped;
522+
}
523+
}
524+
return element;
461525
}
462526

463527
@Override

dfu/src/test/java/org/enginehub/linbus/dfu/LinOpsListTest.java

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@
2929

3030
import java.util.ArrayList;
3131
import java.util.List;
32+
import java.util.Map;
3233
import java.util.stream.Stream;
3334

3435
import static com.google.common.truth.Truth.assertThat;
3536
import static org.enginehub.linbus.dfu.DataResultSubject.assertThat;
36-
import static org.junit.jupiter.api.Assertions.assertThrows;
3737

3838
class LinOpsListTest {
3939

4040
private static final LinOps OPS = LinOps.getInstance();
4141

42+
private static LinCompoundTag wrap(LinTag<?> value) {
43+
return LinCompoundTag.of(Map.of("", value));
44+
}
45+
4246
@Test
4347
void getStreamReadsList() {
4448
assertThat(OPS.getStream(
@@ -76,11 +80,40 @@ void createListOfNothingIsEndTypedList() {
7680
}
7781

7882
@Test
79-
void createListRejectsMixedElementTypes() {
80-
assertThrows(
81-
IllegalArgumentException.class,
82-
() -> OPS.createList(Stream.of(LinIntTag.of(1), LinStringTag.of("x")))
83-
);
83+
@NbtOpsBehavior
84+
void createListWrapsMixedElementTypes() {
85+
assertThat(OPS.createList(Stream.of(LinIntTag.of(1), LinStringTag.of("x"))))
86+
.isEqualTo(LinListTag.of(LinTagType.compoundTag(), List.of(
87+
wrap(LinIntTag.of(1)), wrap(LinStringTag.of("x"))
88+
)));
89+
}
90+
91+
@Test
92+
@NbtOpsBehavior
93+
void getStreamUnwrapsMixedElementTypes() {
94+
LinTag<?> mixed = OPS.createList(Stream.of(LinIntTag.of(1), LinStringTag.of("x")));
95+
assertThat(OPS.getStream(mixed))
96+
.hasStreamResultThat().containsExactly(LinIntTag.of(1), LinStringTag.of("x")).inOrder();
97+
}
98+
99+
@Test
100+
@NbtOpsBehavior
101+
void createListKeepsGenuineCompoundElements() {
102+
LinCompoundTag a = LinCompoundTag.builder().putInt("a", 1).build();
103+
LinCompoundTag b = LinCompoundTag.builder().putInt("b", 2).build();
104+
LinTag<?> list = OPS.createList(Stream.of(a, b));
105+
assertThat(list).isEqualTo(LinListTag.of(LinTagType.compoundTag(), List.of(a, b)));
106+
assertThat(OPS.getStream(list)).hasStreamResultThat().containsExactly(a, b).inOrder();
107+
}
108+
109+
@Test
110+
@NbtOpsBehavior
111+
void createListWrapsOnlyNonCompoundElementsWhenMixedWithCompound() {
112+
LinCompoundTag a = LinCompoundTag.builder().putInt("a", 1).build();
113+
LinTag<?> list = OPS.createList(Stream.of(a, LinIntTag.of(2)));
114+
assertThat(list)
115+
.isEqualTo(LinListTag.of(LinTagType.compoundTag(), List.of(a, wrap(LinIntTag.of(2)))));
116+
assertThat(OPS.getStream(list)).hasStreamResultThat().containsExactly(a, LinIntTag.of(2)).inOrder();
84117
}
85118

86119
@Test
@@ -103,10 +136,25 @@ void mergeToListGrowsList() {
103136
}
104137

105138
@Test
106-
void mergeToListRejectsMismatchedElementInList() {
139+
@NbtOpsBehavior
140+
void mergeToListWrapsMismatchedElement() {
107141
assertThat(OPS.mergeToList(
108142
LinListTag.of(LinTagType.intTag(), List.of(LinIntTag.of(1))), LinStringTag.of("x")
109-
)).hasErrorWithMessageThat().startsWith("Element is not of type ");
143+
)).hasResultThat().isEqualTo(LinListTag.of(LinTagType.compoundTag(), List.of(
144+
wrap(LinIntTag.of(1)), wrap(LinStringTag.of("x"))
145+
)));
146+
}
147+
148+
@Test
149+
@NbtOpsBehavior
150+
void mergeToListGrowsWrappedList() {
151+
LinTag<?> mixed = OPS.mergeToList(
152+
LinListTag.of(LinTagType.intTag(), List.of(LinIntTag.of(1))), LinStringTag.of("x")
153+
).result().orElseThrow();
154+
assertThat(OPS.mergeToList(mixed, LinIntTag.of(2)))
155+
.hasResultThat().isEqualTo(LinListTag.of(LinTagType.compoundTag(), List.of(
156+
wrap(LinIntTag.of(1)), wrap(LinStringTag.of("x")), wrap(LinIntTag.of(2))
157+
)));
110158
}
111159

112160
@Test

0 commit comments

Comments
 (0)