diff --git a/settings.gradle.kts b/settings.gradle.kts index 21b420b..74e1787 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "tweed5" include("test-utils") include("tweed5-annotation-inheritance") include("tweed5-attributes-extension") +include("tweed5-comment-loader-extension") include("tweed5-construct") include("tweed5-core") include("tweed5-default-extensions") diff --git a/tweed5-comment-loader-extension/build.gradle.kts b/tweed5-comment-loader-extension/build.gradle.kts new file mode 100644 index 0000000..c273e08 --- /dev/null +++ b/tweed5-comment-loader-extension/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("de.siphalor.tweed5.base-module") + id("de.siphalor.tweed5.minecraft.mod.dummy") + id("de.siphalor.tweed5.shadow.explicit") +} + +dependencies { + implementation(project(":tweed5-core")) + implementation(project(":tweed5-default-extensions")) + + testImplementation(project(":tweed5-serde-gson")) +} diff --git a/tweed5-comment-loader-extension/gradle.properties b/tweed5-comment-loader-extension/gradle.properties new file mode 100644 index 0000000..e460121 --- /dev/null +++ b/tweed5-comment-loader-extension/gradle.properties @@ -0,0 +1,2 @@ +minecraft.mod.name = Tweed 5 Comment Loader Extension +minecraft.mod.description = Tweed 5 module that allows dynamically loading comments from data files, e.g., for i18n diff --git a/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentLoaderExtension.java b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentLoaderExtension.java new file mode 100644 index 0000000..cee9ec3 --- /dev/null +++ b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentLoaderExtension.java @@ -0,0 +1,16 @@ +package de.siphalor.tweed5.commentloaderextension.api; + +import de.siphalor.tweed5.commentloaderextension.impl.CommentLoaderExtensionImpl; +import de.siphalor.tweed5.core.api.extension.TweedExtension; +import de.siphalor.tweed5.dataapi.api.TweedDataReader; + +public interface CommentLoaderExtension extends TweedExtension { + Class DEFAULT = CommentLoaderExtensionImpl.class; + String EXTENSION_ID = "comment-loader"; + + void loadComments(TweedDataReader reader, CommentPathProcessor pathProcessor); + + default String getId() { + return EXTENSION_ID; + } +} diff --git a/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentPathProcessor.java b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentPathProcessor.java new file mode 100644 index 0000000..1507cf5 --- /dev/null +++ b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/CommentPathProcessor.java @@ -0,0 +1,12 @@ +package de.siphalor.tweed5.commentloaderextension.api; + +public interface CommentPathProcessor { + MatchStatus matches(String path); + String process(String path); + + enum MatchStatus { + YES, + NO, + MAYBE_DEEPER, + } +} diff --git a/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/package-info.java b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/package-info.java new file mode 100644 index 0000000..c0ddbb2 --- /dev/null +++ b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/api/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package de.siphalor.tweed5.commentloaderextension.api; + +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImpl.java b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImpl.java new file mode 100644 index 0000000..07d5b8d --- /dev/null +++ b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImpl.java @@ -0,0 +1,212 @@ +package de.siphalor.tweed5.commentloaderextension.impl; + +import de.siphalor.tweed5.commentloaderextension.api.CommentLoaderExtension; +import de.siphalor.tweed5.commentloaderextension.api.CommentPathProcessor; +import de.siphalor.tweed5.core.api.container.ConfigContainer; +import de.siphalor.tweed5.core.api.container.ConfigContainerSetupPhase; +import de.siphalor.tweed5.core.api.extension.TweedExtensionSetupContext; +import de.siphalor.tweed5.core.api.middleware.Middleware; +import de.siphalor.tweed5.dataapi.api.IntuitiveVisitingTweedDataReader; +import de.siphalor.tweed5.dataapi.api.TweedDataReadException; +import de.siphalor.tweed5.dataapi.api.TweedDataReader; +import de.siphalor.tweed5.dataapi.api.TweedDataVisitor; +import de.siphalor.tweed5.dataapi.api.decoration.TweedDataDecoration; +import de.siphalor.tweed5.defaultextensions.comment.api.CommentExtension; +import de.siphalor.tweed5.defaultextensions.comment.api.CommentModifyingExtension; +import de.siphalor.tweed5.defaultextensions.comment.api.CommentProducer; +import de.siphalor.tweed5.defaultextensions.pather.api.PathTracking; +import de.siphalor.tweed5.defaultextensions.pather.api.PathTrackingConfigEntryVisitor; +import de.siphalor.tweed5.patchwork.api.PatchworkPartAccess; +import lombok.Getter; +import lombok.Value; +import lombok.extern.apachecommons.CommonsLog; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +@CommonsLog +public class CommentLoaderExtensionImpl implements CommentLoaderExtension, CommentModifyingExtension { + private final ConfigContainer configContainer; + private final PatchworkPartAccess loadedCommentAccess; + private @Nullable CommentExtension commentExtension; + + public CommentLoaderExtensionImpl(ConfigContainer configContainer, TweedExtensionSetupContext setupContext) { + this.configContainer = configContainer; + setupContext.registerExtension(CommentExtension.class); + + loadedCommentAccess = setupContext.registerEntryExtensionData(String.class); + } + + @Override + public void extensionsFinalized() { + commentExtension = configContainer.extension(CommentExtension.class) + .orElseThrow(() -> new IllegalStateException("CommentExtension not found")); + } + + @Override + public Middleware commentMiddleware() { + return new Middleware() { + @Override + public String id() { + return EXTENSION_ID; + } + + @Override + public Set mustComeBefore() { + return Collections.singleton(Middleware.DEFAULT_START); + } + + @Override + public Set mustComeAfter() { + return Collections.emptySet(); + } + + @Override + public CommentProducer process(CommentProducer inner) { + return entry -> { + String loadedComment = entry.extensionsData().get(loadedCommentAccess); + String innerComment = inner.createComment(entry); + if (loadedComment != null) { + if (innerComment.isEmpty()) { + return loadedComment; + } else { + return innerComment + loadedComment; + } + } + return innerComment; + }; + } + }; + } + + @Override + public void loadComments(TweedDataReader reader, CommentPathProcessor pathProcessor) { + if (configContainer.setupPhase().compareTo(ConfigContainerSetupPhase.EXTENSIONS_SETUP) <= 0) { + throw new IllegalStateException("Comments cannot be loaded before the extensions are finalized"); + } + + CollectingCommentsVisitor collectingCommentsVisitor = new CollectingCommentsVisitor(pathProcessor); + try { + new IntuitiveVisitingTweedDataReader(collectingCommentsVisitor).readMap(reader); + } catch (TweedDataReadException e) { + log.error("Failed to load comments", e); + } + + Map commentsByKey = collectingCommentsVisitor.commentsByKey(); + PathTracking pathTracking = new PathTracking(); + configContainer.rootEntry().visitInOrder(new PathTrackingConfigEntryVisitor( + entry -> { + String key = pathTracking.currentPath(); + if (!key.isEmpty() && key.charAt(0) == '.') { + key = key.substring(1); + } + entry.extensionsData().set(loadedCommentAccess, commentsByKey.get(key)); + }, + pathTracking + )); + + if (configContainer.setupPhase().compareTo(ConfigContainerSetupPhase.INITIALIZED) >= 0) { + assert commentExtension != null; + commentExtension.recomputeFullComments(); + } + } + + private static class CollectingCommentsVisitor implements TweedDataVisitor { + private final CommentPathProcessor pathProcessor; + @Getter + private final Map commentsByKey = new HashMap<>(); + private final Deque stateStack = new ArrayDeque<>(); + private State currentState = new State(CommentPathProcessor.MatchStatus.MAYBE_DEEPER, ""); + + public CollectingCommentsVisitor(CommentPathProcessor pathProcessor) { + this.pathProcessor = pathProcessor; + stateStack.push(currentState); + } + + @Override + public void visitNull() {} + + @Override + public void visitBoolean(boolean value) {} + + @Override + public void visitByte(byte value) {} + + @Override + public void visitShort(short value) {} + + @Override + public void visitInt(int value) {} + + @Override + public void visitLong(long value) {} + + @Override + public void visitFloat(float value) {} + + @Override + public void visitDouble(double value) {} + + @Override + public void visitString(String value) { + if (currentState.matchStatus() == CommentPathProcessor.MatchStatus.YES) { + commentsByKey.put(pathProcessor.process(currentState.key()), value); + } + } + + @Override + public void visitListStart() { + stateStack.push(State.IGNORED); + } + + @Override + public void visitListEnd() { + stateStack.pop(); + } + + @Override + public void visitMapStart() { + stateStack.push(currentState); + currentState = State.IGNORED; + } + + @Override + public void visitMapEntryKey(String key) { + State state = stateStack.peek(); + assert state != null; + if (state.matchStatus() == CommentPathProcessor.MatchStatus.NO) { + return; + } + + String fullPath; + if (state.key().isEmpty()) { + fullPath = key; + } else { + fullPath = state.key() + "." + key; + } + + CommentPathProcessor.MatchStatus matchStatus = pathProcessor.matches(fullPath); + if (matchStatus == CommentPathProcessor.MatchStatus.NO) { + currentState = State.IGNORED; + } else { + currentState = new State(matchStatus, fullPath); + } + } + + @Override + public void visitMapEnd() { + currentState = stateStack.pop(); + } + + @Override + public void visitDecoration(TweedDataDecoration decoration) {} + + @Value + private static class State { + private static final State IGNORED = new State(CommentPathProcessor.MatchStatus.NO, ""); + + CommentPathProcessor.MatchStatus matchStatus; + String key; + } + } +} diff --git a/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/package-info.java b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/package-info.java new file mode 100644 index 0000000..40e4670 --- /dev/null +++ b/tweed5-comment-loader-extension/src/main/java/de/siphalor/tweed5/commentloaderextension/impl/package-info.java @@ -0,0 +1,6 @@ +@ApiStatus.Internal +@NullMarked +package de.siphalor.tweed5.commentloaderextension.impl; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-comment-loader-extension/src/test/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImplTest.java b/tweed5-comment-loader-extension/src/test/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImplTest.java new file mode 100644 index 0000000..bd9589f --- /dev/null +++ b/tweed5-comment-loader-extension/src/test/java/de/siphalor/tweed5/commentloaderextension/impl/CommentLoaderExtensionImplTest.java @@ -0,0 +1,112 @@ +package de.siphalor.tweed5.commentloaderextension.impl; + +import com.google.gson.GsonBuilder; +import de.siphalor.tweed5.commentloaderextension.api.CommentLoaderExtension; +import de.siphalor.tweed5.commentloaderextension.api.CommentPathProcessor; +import de.siphalor.tweed5.core.impl.DefaultConfigContainer; +import de.siphalor.tweed5.core.impl.entry.SimpleConfigEntryImpl; +import de.siphalor.tweed5.core.impl.entry.StaticMapCompoundConfigEntryImpl; +import de.siphalor.tweed5.defaultextensions.comment.api.CommentExtension; +import de.siphaolor.tweed5.data.gson.GsonReader; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static de.siphalor.tweed5.testutils.generic.MapTestUtils.sequencedMap; +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class CommentLoaderExtensionImplTest { + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + @SneakyThrows + void test() { + var configContainer = new DefaultConfigContainer>(); + configContainer.registerExtension(CommentLoaderExtension.class); + configContainer.finishExtensionSetup(); + + var nestedIntEntry = new SimpleConfigEntryImpl<>(configContainer, Integer.class); + var nestedEntry = new StaticMapCompoundConfigEntryImpl<>( + configContainer, + (Class>) (Class) Map.class, + HashMap::newHashMap, + sequencedMap(List.of( + entry("int", nestedIntEntry) + )) + ); + var intEntry = new SimpleConfigEntryImpl<>(configContainer, Integer.class); + var rootEntry = new StaticMapCompoundConfigEntryImpl<>( + configContainer, + (Class>)(Class) Map.class, + HashMap::newHashMap, + sequencedMap(List.of( + entry("nested", nestedEntry), + entry("int", intEntry) + )) + ); + + configContainer.attachTree(rootEntry); + configContainer.initialize(); + + CommentLoaderExtension extension = configContainer.extension(CommentLoaderExtension.class).orElseThrow(); + + // language=json + var text = """ + { + "test": { + "description": "Root comment" + }, + "test.int.description": "What an int!", + "test.nested": { + "description": "Comment for nested entry", + "int.description": "A cool nested entry" + } + } + """; + try (var reader = new GsonReader(new GsonBuilder().create().newJsonReader(new StringReader(text)))) { + extension.loadComments( + reader, + new CommentPathProcessor() { + private static final String PREFIX_RAW = "test"; + private static final String PREFIX = PREFIX_RAW + "."; + private static final String SUFFIX = ".description"; + + @Override + public MatchStatus matches(String path) { + if (path.equals(PREFIX_RAW)) { + return MatchStatus.MAYBE_DEEPER; + } else if (path.startsWith(PREFIX)) { + if (path.endsWith(SUFFIX)) { + return MatchStatus.YES; + } else { + return MatchStatus.MAYBE_DEEPER; + } + } else { + return MatchStatus.NO; + } + } + + @Override + public String process(String path) { + return path.substring( + PREFIX.length(), + Math.max(PREFIX.length(), path.length() - SUFFIX.length()) + ); + } + } + ); + } + + CommentExtension commentExtension = configContainer.extension(CommentExtension.class).orElseThrow(); + + assertThat(commentExtension.getFullComment(rootEntry)).isEqualTo("Root comment"); + assertThat(commentExtension.getFullComment(nestedEntry)).isEqualTo("Comment for nested entry"); + assertThat(commentExtension.getFullComment(nestedIntEntry)).isEqualTo("A cool nested entry"); + assertThat(commentExtension.getFullComment(intEntry)).isEqualTo("What an int!"); + } +} diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/api/CommentExtension.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/api/CommentExtension.java index 9e11d40..3de92a5 100644 --- a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/api/CommentExtension.java +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/api/CommentExtension.java @@ -21,5 +21,7 @@ public interface CommentExtension extends TweedExtension { void setBaseComment(ConfigEntry configEntry, String baseComment); + void recomputeFullComments(); + @Nullable String getFullComment(ConfigEntry configEntry); } diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/impl/CommentExtensionImpl.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/impl/CommentExtensionImpl.java index 28b2492..fc6be80 100644 --- a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/impl/CommentExtensionImpl.java +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/comment/impl/CommentExtensionImpl.java @@ -65,9 +65,16 @@ public class CommentExtensionImpl implements ReadWriteRelatedExtension, CommentE } @Override - public void initEntry(ConfigEntry configEntry) { - CustomEntryData entryData = getOrCreateCustomEntryData(configEntry); - entryData.commentProducer(middlewareContainer.process(entry -> entryData.baseComment())); + public void initialize() { + recomputeFullComments(); + } + + @Override + public void recomputeFullComments() { + configContainer.rootEntry().visitInOrder(entry -> { + CustomEntryData entryData = getOrCreateCustomEntryData(entry); + entryData.commentProducer(middlewareContainer.process(_entry -> entryData.baseComment())); + }); } private CustomEntryData getOrCreateCustomEntryData(ConfigEntry entry) { diff --git a/tweed5-serde-api/src/main/java/de/siphalor/tweed5/dataapi/api/IntuitiveVisitingTweedDataReader.java b/tweed5-serde-api/src/main/java/de/siphalor/tweed5/dataapi/api/IntuitiveVisitingTweedDataReader.java new file mode 100644 index 0000000..003a7ca --- /dev/null +++ b/tweed5-serde-api/src/main/java/de/siphalor/tweed5/dataapi/api/IntuitiveVisitingTweedDataReader.java @@ -0,0 +1,77 @@ +package de.siphalor.tweed5.dataapi.api; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class IntuitiveVisitingTweedDataReader { + private final TweedDataVisitor visitor; + + public void readValue(TweedDataReader reader) throws TweedDataReadException { + TweedDataToken token = reader.peekToken(); + if (token.isNull()) { + reader.readToken(); + visitor.visitNull(); + } else if (token.isListStart()) { + readList(reader); + } else if (token.isMapStart()) { + readMap(reader); + } else if (token.canReadAsByte()) { + visitor.visitByte(reader.readToken().readAsByte()); + } else if (token.canReadAsShort()) { + visitor.visitShort(reader.readToken().readAsShort()); + } else if (token.canReadAsInt()) { + visitor.visitInt(reader.readToken().readAsInt()); + } else if (token.canReadAsLong()) { + visitor.visitLong(reader.readToken().readAsLong()); + } else if (token.canReadAsFloat()) { + visitor.visitFloat(reader.readToken().readAsFloat()); + } else if (token.canReadAsDouble()) { + visitor.visitDouble(reader.readToken().readAsDouble()); + } else if (token.canReadAsBoolean()) { + visitor.visitBoolean(reader.readToken().readAsBoolean()); + } else if (token.canReadAsString()) { + visitor.visitString(reader.readToken().readAsString()); + } + } + + public void readList(TweedDataReader reader) throws TweedDataReadException { + TweedDataToken token = reader.readToken(); + if (!token.isListStart()) { + throw TweedDataReadException.builder().message("Expected list but got " + token).build(); + } + + visitor.visitListStart(); + while (true) { + token = reader.peekToken(); + if (token.isListEnd()) { + visitor.visitListEnd(); + reader.readToken(); + break; + } else { + readValue(reader); + } + } + } + + public void readMap(TweedDataReader reader) throws TweedDataReadException { + TweedDataToken token = reader.readToken(); + if (!token.isMapStart()) { + throw TweedDataReadException.builder().message("Expected map but got " + token).build(); + } + + visitor.visitMapStart(); + while (true) { + token = reader.peekToken(); + if (token.isMapEnd()) { + reader.readToken(); + visitor.visitMapEnd(); + break; + } else if (token.isMapEntryKey()) { + visitor.visitMapEntryKey(reader.readToken().readAsString()); + readValue(reader); + } else { + throw TweedDataReadException.builder().message("Expected map end or entry key but got " + token).build(); + } + } + } +} diff --git a/tweed5-serde-gson/build.gradle.kts b/tweed5-serde-gson/build.gradle.kts index 6c12db2..bb01326 100644 --- a/tweed5-serde-gson/build.gradle.kts +++ b/tweed5-serde-gson/build.gradle.kts @@ -6,7 +6,7 @@ plugins { dependencies { implementation(project(":tweed5-serde-api")) - implementation(libs.gson) + api(libs.gson) testImplementation(project(":serde-json-test-utils")) }