diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/BlankLine.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/BlankLine.java new file mode 100644 index 0000000..7f2ff3f --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/BlankLine.java @@ -0,0 +1,12 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import java.io.IOException; +import java.io.Writer; + +public record BlankLine(String content) implements PreProcessorLine { + + @Override + public void append(Writer writer) throws IOException { + writer.write(content); + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/CommentLine.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/CommentLine.java new file mode 100644 index 0000000..0f64c36 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/CommentLine.java @@ -0,0 +1,22 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import java.io.IOException; +import java.io.Writer; + +public class CommentLine implements PreProcessorLine { + + private final String indent; + public final String value; + + public CommentLine(String indent, String value) { + this.indent = indent; + this.value = value; + } + + @Override + public void append(Writer writer) throws IOException { + writer.write(indent); + writer.write('#'); + writer.write(value); + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/EnvironmentComment.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/EnvironmentComment.java new file mode 100644 index 0000000..76a9468 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/EnvironmentComment.java @@ -0,0 +1,34 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import java.util.Objects; + +public class EnvironmentComment { + + public final String variableName; + private final String variableNameLower; + + public final String variableType; + private final String variableTypeLower; + + public final CommentLine commentLine; + + public EnvironmentComment(String variableName, String variableType, CommentLine commentLine) { + this.variableName = variableName; + this.variableNameLower = variableName.toLowerCase(); + this.variableType = variableType; + this.variableTypeLower = variableType.toLowerCase(); + this.commentLine = commentLine; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof EnvironmentComment that)) return false; + return Objects.equals(variableNameLower, that.variableNameLower) && Objects.equals(variableTypeLower, that.variableTypeLower); + } + + @Override + public int hashCode() { + return Objects.hash(variableNameLower, variableTypeLower); + } +} \ No newline at end of file diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/InputConflict.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/InputConflict.java new file mode 100644 index 0000000..3e6e17f --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/InputConflict.java @@ -0,0 +1,6 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +public enum InputConflict { + INVALID_KEY_CHARACTERS, + BLANK_VALUE, +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/KeyValueLine.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/KeyValueLine.java new file mode 100644 index 0000000..a764c9c --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/KeyValueLine.java @@ -0,0 +1,47 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.Writer; +import java.util.List; + +public class KeyValueLine implements PreProcessorLine { + + public final String content; + public final String key; + public final String value; + public final List attachedComments; + + // This line-number won't be synchronized when migrating - it's only supposed to be + // accessed on the file migrating *from*, as to calculate reference-distance metrics + public final int lineNumberAsRead; + + public @Nullable KeyValueLine previous; + public @Nullable KeyValueLine next; + + public boolean hadBlankBefore; + public boolean hadBlankAfter; + + public KeyValueLine( + String content, String key, String value, + List attachedComments, + int lineNumberAsRead + ) { + this.content = content; + this.key = key; + this.value = value; + this.attachedComments = attachedComments; + this.lineNumberAsRead = lineNumberAsRead; + } + + @Override + public void append(Writer writer) throws IOException { + for (var attachedComment : attachedComments) { + attachedComment.append(writer); + writer.write('\n'); + } + + writer.write(content); + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessConflict.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessConflict.java new file mode 100644 index 0000000..33c32d0 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessConflict.java @@ -0,0 +1,6 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +public enum PreProcessConflict { + VARIABLE_NOT_FOUND, + MALFORMED_TEMPORARY_VARIABLE +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessor.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessor.java new file mode 100644 index 0000000..e0a07b4 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessor.java @@ -0,0 +1,237 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import me.blvckbytes.bbconfigmapper.YamlConfig; +import org.jetbrains.annotations.Nullable; +import org.yaml.snakeyaml.nodes.*; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +public class PreProcessor { + + @FunctionalInterface + public interface ScalarNodeHandler { + void handle(ScalarNode node) throws Exception; + } + + private final Field scalarNodeValueField; + + public PreProcessor() throws Exception { + this.scalarNodeValueField = ScalarNode.class.getDeclaredField("value"); + this.scalarNodeValueField.setAccessible(true); + } + + public String preProcess(String input, PreProcessorInput substitutions) { + var result = new StringBuilder(); + + var contentBegin = -1; + var substitutionBegin = -1; + + for (var i = 0; i < input.length(); ++i) { + var currentChar = input.charAt(i); + + if (currentChar == '@' && i != input.length() - 1 && input.charAt(i + 1) == '{') { + if (contentBegin >= 0) { + result.append(input, contentBegin, i); + contentBegin = -1; + } + + substitutionBegin = i; + continue; + } + + if (substitutionBegin >= 0 && currentChar == '}') { + var substitutionContent = input.substring(substitutionBegin + 2, i); + var openingParenthesisIndex = substitutionContent.indexOf('('); + var temporaryVariables = Map.of(); + + if (openingParenthesisIndex >= 0) { + var closingParenthesisIndex = substitutionContent.indexOf(')'); + + if (closingParenthesisIndex > openingParenthesisIndex) { + var temporaryVariablesContent = substitutionContent.substring(openingParenthesisIndex + 1, closingParenthesisIndex); + temporaryVariables = parseTemporaryVariables(temporaryVariablesContent, openingParenthesisIndex + 1); + substitutionContent = substitutionContent.substring(0, openingParenthesisIndex).trim(); + } + } + + var substitutionKey = substitutionContent; + String substitution = substitutions.getValue(substitutionKey); + + if (substitution == null) + throw new PreProcessorException(substitutionBegin, PreProcessConflict.VARIABLE_NOT_FOUND); + + result.append(renderInterpolations(substitution, temporaryVariables)); + substitutionBegin = -1; + continue; + } + + if (substitutionBegin >= 0) + continue; + + if (contentBegin == -1) + contentBegin = i; + } + + if (contentBegin >= 0) + result.append(input.substring(contentBegin)); + + return result.toString(); + } + + public Map parseTemporaryVariables(String input, int beginIndex) { + var result = new HashMap(); + + var assignments = input.split(";"); + + for (var assignment : assignments) { + var assignmentParts = assignment.split("=", 2); + + if (assignmentParts.length != 2) + throw new PreProcessorException(beginIndex, PreProcessConflict.MALFORMED_TEMPORARY_VARIABLE); + + var key = assignmentParts[0].strip().toLowerCase(); + var value = assignmentParts[1].strip(); + + if (key.isBlank() || value.isBlank()) + throw new PreProcessorException(beginIndex, PreProcessConflict.MALFORMED_TEMPORARY_VARIABLE); + + result.put(key, value); + } + + return result; + } + + public void forEachScalarValue(YamlConfig config, ScalarNodeHandler handler) throws Exception { + forEachScalarValue(config.getRootNode(), null, config.getExpressionMarkerSuffix(), handler); + } + + public void setScalarValue(ScalarNode node, String value) throws Exception { + this.scalarNodeValueField.set(node, value); + } + + public void forEachScalarValue( + Node valueNode, @Nullable ScalarNode keyNode, + String expressionMarkerSuffix, + ScalarNodeHandler handler + ) throws Exception { + if (valueNode instanceof ScalarNode scalarNode) { + handler.handle(scalarNode); + + if (keyNode != null && !keyNode.getValue().endsWith(expressionMarkerSuffix)) + setScalarValue(keyNode, keyNode.getValue() + expressionMarkerSuffix); + + return; + } + + if (valueNode instanceof MappingNode mappingNode) { + for (var entry : mappingNode.getValue()) { + if (!(entry.getKeyNode() instanceof ScalarNode currentKeyNode)) + continue; + + forEachScalarValue( + entry.getValueNode(), currentKeyNode, + expressionMarkerSuffix, + handler + ); + } + + return; + } + + if (valueNode instanceof SequenceNode sequenceNode) { + for (var entry : sequenceNode.getValue()) { + forEachScalarValue( + entry, keyNode, + expressionMarkerSuffix, + handler + ); + } + } + } + + private boolean isValidSubstitutionChar(char c) { + return ( + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '_' || c == '-' + ); + } + + public String renderInterpolations(String input, Map temporaryVariables) { + var result = new StringBuilder(); + + var stringBuffer = new StringBuilder(); + var interpolationBuffer = new StringBuilder(); + + var wasPreviousCharBackslash = false; + + for (var i = 0; i < input.length(); ++i) { + var currentChar = input.charAt(i); + + var isEscaped = wasPreviousCharBackslash; + wasPreviousCharBackslash = currentChar == '\\'; + + if (currentChar == '{') { + if (isEscaped) + stringBuffer.setLength(stringBuffer.length() - 1); + else { + interpolationBuffer.append(currentChar); + continue; + } + } + + if (!interpolationBuffer.isEmpty()) { + if (currentChar != '}') { + if (!isValidSubstitutionChar(currentChar)) { + stringBuffer.append(interpolationBuffer); + stringBuffer.append(currentChar); + interpolationBuffer.setLength(0); + continue; + } + + interpolationBuffer.append(currentChar); + continue; + } + + if (!stringBuffer.isEmpty()) { + if (!result.isEmpty()) + result.append(" & "); + + result.append('"').append(stringBuffer).append('"').append(" & "); + stringBuffer.setLength(0); + } + + var substitution = interpolationBuffer.substring(1); // Leading '{' of begin-marker + var temporaryValue = temporaryVariables.get(substitution.toLowerCase()); + + if (temporaryValue != null) + substitution = temporaryValue; + + result.append(substitution); + + interpolationBuffer.setLength(0); + continue; + } + + if (currentChar == '"') + stringBuffer.append('\\'); + + stringBuffer.append(currentChar); + } + + // Unterminated interpolation - treat as string + if (!interpolationBuffer.isEmpty()) + stringBuffer.append(interpolationBuffer); + + if (!stringBuffer.isEmpty()) { + if (!result.isEmpty()) + result.append(" & "); + + result.append('"').append(stringBuffer).append('"'); + } + + return result.toString(); + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorException.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorException.java new file mode 100644 index 0000000..bf514d4 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorException.java @@ -0,0 +1,12 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +public class PreProcessorException extends RuntimeException { + + public final int charIndex; + public final PreProcessConflict conflict; + + public PreProcessorException(int charIndex, PreProcessConflict conflict) { + this.charIndex = charIndex; + this.conflict = conflict; + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInput.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInput.java new file mode 100644 index 0000000..fcfa9e5 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInput.java @@ -0,0 +1,343 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.util.*; + +public class PreProcessorInput { + + private static final int LINE_BUFFER_SIZE = 1024; + + private final Map parsedLineByKey; + private final List lines; + + public PreProcessorInput() { + this.parsedLineByKey = new HashMap<>(); + this.lines = new ArrayList<>(); + } + + public Collection getKeys() { + return Collections.unmodifiableSet(this.parsedLineByKey.keySet()); + } + + public @Nullable String getValue(String key) { + var parsedLine = parsedLineByKey.get(key.toLowerCase()); + + if (parsedLine == null) + return null; + + return parsedLine.value; + } + + public void load(Reader reader) throws IOException { + parsedLineByKey.clear(); + lines.clear(); + + char[] lineBuffer = new char[LINE_BUFFER_SIZE]; + int lineBufferIndex = 0; + int lineCounter = 0; + var readSuccess = true; + + KeyValueLine previousKeyValueLine = null; + var consecutiveComments = new ArrayList(); + + PreProcessorLine previousLine = null; + + while (readSuccess) { + readSuccess = reader.read(lineBuffer, lineBufferIndex, 1) != -1; + + if ( + // EOS reached, and line-buffer is not empty + (!readSuccess && lineBufferIndex != 0) || + // Newline encountered + (lineBuffer[lineBufferIndex] == '\n') + ) { + var lineContent = new String(lineBuffer, 0, lineBufferIndex); + var currentLine = parseLine(lineContent, ++lineCounter, consecutiveComments); + + if (currentLine instanceof CommentLine commentLine) + consecutiveComments.add(commentLine); + else { + // Dangling comments, not attached to any key + if (!(currentLine instanceof KeyValueLine)) + this.lines.addAll(consecutiveComments); + + consecutiveComments.clear(); + } + + if (currentLine instanceof KeyValueLine keyValueLine) { + this.lines.add(keyValueLine); + this.parsedLineByKey.put(keyValueLine.key.toLowerCase(), keyValueLine); + + if (previousKeyValueLine != null) { + keyValueLine.previous = previousKeyValueLine; + previousKeyValueLine.next = keyValueLine; + } + + previousKeyValueLine = keyValueLine; + + if (previousLine instanceof BlankLine) + keyValueLine.hadBlankBefore = true; + } + + else if (currentLine instanceof BlankLine) { + this.lines.add(currentLine); + + if (previousLine instanceof KeyValueLine keyValueLine) + keyValueLine.hadBlankAfter = true; + } + + previousLine = currentLine; + lineBufferIndex = 0; + continue; + } + + ++lineBufferIndex; + } + + // Dangling comments at the very end + if (!consecutiveComments.isEmpty()) + this.lines.addAll(consecutiveComments); + } + + private int indexOfWhitespace(String input, int fromIndex, boolean isWhitespace) { + for (var i = fromIndex; i < input.length(); ++i) { + if ((input.charAt(i) == ' ') == isWhitespace) + return i; + } + + return -1; + } + + private PreProcessorLine parseLine(String line, int lineNumber, List previousConsecutiveComments) { + int keyBegin; + + if ((keyBegin = indexOfWhitespace(line, 0, false)) < 0) + return new BlankLine(line); + + if (line.charAt(keyBegin) == '#') { + return new CommentLine( + line.substring(0, keyBegin), + keyBegin == line.length() - 1 + ? "" + : line.substring(keyBegin + 1) + ); + } + + int keyEnd = indexOfWhitespace(line, keyBegin + 1, true); + + if (keyEnd < 0) + keyEnd = line.length() - 1; + else + --keyEnd; + + var key = line.substring(keyBegin, keyEnd + 1); + + for (var keyIndex = 0; keyIndex < key.length(); ++keyIndex) { + var keyChar = key.charAt(keyIndex); + + if (!( + (keyChar >= 'A' && keyChar <= 'Z') || + (keyChar >= '0' && keyChar <= '9') || + keyChar == '-' || + keyChar == '_' + )) + throw new PreProcessorInputException(lineNumber, InputConflict.INVALID_KEY_CHARACTERS); + } + + var valueBegin = indexOfWhitespace(line, keyEnd + 1, false); + + if (valueBegin < 0) + throw new PreProcessorInputException(lineNumber, InputConflict.BLANK_VALUE); + + var valueEnd = line.length() - 1; + var valueLength = valueEnd - valueBegin + 1; + + if (valueLength > 1 && line.charAt(valueBegin) == '\\') { + var nextChar = line.charAt(valueBegin + 1); + + if ( + // \ -> + nextChar == ' ' || + // \\ -> \ + valueLength > 2 && nextChar == '\\' && line.charAt(valueBegin + 2) == ' ' + ) + ++valueBegin; + } + + if (valueLength > 1 && line.charAt(valueEnd) == '\\') { + var previousChar = line.charAt(valueEnd - 1); + + if ( + // \ -> + previousChar == ' ' || + // \\ -> \ + valueLength > 2 && previousChar == '\\' && line.charAt(valueEnd - 2) == ' ' + ) + --valueEnd; + } + + var value = line.substring(valueBegin, valueEnd + 1); + return new KeyValueLine(line, key, value, new ArrayList<>(previousConsecutiveComments), lineNumber); + } + + private @Nullable KeyValueLine checkExistenceByWalkingLinks(KeyValueLine line, boolean previous) { + KeyValueLine currentLine = line; + + while ((currentLine = (previous ? currentLine.previous : currentLine.next)) != null) { + if (!this.parsedLineByKey.containsKey(currentLine.key.toLowerCase())) + continue; + + return currentLine; + } + + return null; + } + + private boolean extendMissingKey(KeyValueLine missingLine) { + if (this.parsedLineByKey.containsKey(missingLine.key.toLowerCase())) + return false; + + this.parsedLineByKey.put(missingLine.key.toLowerCase(), missingLine); + + KeyValueLine referencePrior = checkExistenceByWalkingLinks(missingLine, true); + KeyValueLine referenceAfter = checkExistenceByWalkingLinks(missingLine, false); + + if (referencePrior == null && referenceAfter == null) { + // Space them out, as to indicate that they do not belong to another paragraph of keys + this.lines.add(new BlankLine("")); + this.lines.add(missingLine); + return true; + } + + // Near relative to the file migrating to, as that's the only metric we have + KeyValueLine nearestReference; + + if (referencePrior == null) + nearestReference = referenceAfter; + else if (referenceAfter == null) + nearestReference = referencePrior; + else { + var referencePriorDistance = Math.abs(missingLine.lineNumberAsRead - referencePrior.lineNumberAsRead); + var referenceAfterDistance = Math.abs(missingLine.lineNumberAsRead - referenceAfter.lineNumberAsRead); + nearestReference = referencePriorDistance <= referenceAfterDistance ? referencePrior : referenceAfter; + } + + for (var lineIndex = 0; lineIndex < this.lines.size(); ++lineIndex) { + if (!(lines.get(lineIndex) instanceof KeyValueLine keyValueLine)) + continue; + + if (!(keyValueLine.key.equals(nearestReference.key))) + continue; + + var addIndex = lineIndex + (nearestReference == referencePrior ? 1 : 0); + + this.lines.add(addIndex, missingLine); + + // Also add blank lines if not yet existing, to signal paragraphs + + if (missingLine.hadBlankBefore) { + if (!(addIndex > 0 && this.lines.get(addIndex - 1) instanceof BlankLine)) + this.lines.add(addIndex, new BlankLine("")); + } + + if (missingLine.hadBlankAfter) { + if (!(addIndex + 1 < this.lines.size() && this.lines.get(addIndex + 1) instanceof BlankLine)) + this.lines.add(addIndex + 1, new BlankLine("")); + } + + return true; + } + + throw new IllegalStateException("Could not find a previously located reference-line: " + nearestReference.content); + } + + private @Nullable EnvironmentComment tryParseEnvironmentComment(CommentLine line) { + var hyphenIndex = line.value.indexOf('-'); + + if (hyphenIndex < 0) + return null; + + var nameBeginIndex = indexOfWhitespace(line.value, hyphenIndex + 1, false); + + if (nameBeginIndex < 0) + return null; + + var colonIndex = line.value.indexOf(':', nameBeginIndex + 1); + + if (colonIndex < 0) + return null; + + var typeBeginIndex = indexOfWhitespace(line.value, colonIndex + 1, false); + + if (typeBeginIndex < 0) + return null; + + return new EnvironmentComment( + line.value.substring(nameBeginIndex, colonIndex), + line.value.substring(typeBeginIndex).stripTrailing(), + line + ); + } + + private void updateEnvironmentComments(KeyValueLine otherLine) { + var localLine = parsedLineByKey.get(otherLine.key.toLowerCase()); + + if (localLine == null) + throw new IllegalStateException("Assumed local existence of key=" + otherLine.key); + + var otherEnvironmentComments = new LinkedHashSet(); + + for (var otherComment : otherLine.attachedComments) { + var otherEnvironmentComment = tryParseEnvironmentComment(otherComment); + + if (otherEnvironmentComment != null) + otherEnvironmentComments.add(otherEnvironmentComment); + } + + for (var commentIndex = localLine.attachedComments.size() - 1; commentIndex >= 0; --commentIndex) { + var localComment = localLine.attachedComments.get(commentIndex); + var localEnvironmentComment = tryParseEnvironmentComment(localComment); + + if (localEnvironmentComment == null) + continue; + + // This variable does no longer exist + if (!otherEnvironmentComments.remove(localEnvironmentComment)) + localLine.attachedComments.remove(commentIndex); + } + + // Add missing variables to the very bottom + for (var otherEnvironmentComment : otherEnvironmentComments) + localLine.attachedComments.add(otherEnvironmentComment.commentLine); + } + + public int migrateTo(PreProcessorInput other) { + var extendedKeys = 0; + + for (var otherLine : other.parsedLineByKey.values()) { + if (extendMissingKey(otherLine)) { + ++extendedKeys; + continue; + } + + updateEnvironmentComments(otherLine); + } + + return extendedKeys; + } + + public void save(Writer writer) throws IOException { + for (var i = 0; i < lines.size(); ++i) { + var line = lines.get(i); + + if (i != 0) + writer.write('\n'); + + line.append(writer); + } + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInputException.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInputException.java new file mode 100644 index 0000000..6ecd1c8 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorInputException.java @@ -0,0 +1,12 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +public class PreProcessorInputException extends RuntimeException { + + public final int lineNumber; + public final InputConflict conflict; + + public PreProcessorInputException(int lineNumber, InputConflict conflict) { + this.lineNumber = lineNumber; + this.conflict = conflict; + } +} diff --git a/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorLine.java b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorLine.java new file mode 100644 index 0000000..f20c351 --- /dev/null +++ b/src/main/java/me/blvckbytes/bbconfigmapper/preprocessor/PreProcessorLine.java @@ -0,0 +1,10 @@ +package me.blvckbytes.bbconfigmapper.preprocessor; + +import java.io.IOException; +import java.io.Writer; + +public interface PreProcessorLine { + + void append(Writer writer) throws IOException; + +} diff --git a/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorInputTests.java b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorInputTests.java new file mode 100644 index 0000000..8ecccdd --- /dev/null +++ b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorInputTests.java @@ -0,0 +1,81 @@ +package me.blvckbytes.bbconfigmapper; + +import me.blvckbytes.bbconfigmapper.preprocessor.InputConflict; +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessorInput; +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessorInputException; +import org.junit.jupiter.api.Test; + +import java.io.*; + +import static org.junit.jupiter.api.Assertions.*; + +public class PreProcessorInputTests extends PreProcessorTestBase { + + @Test + public void shouldParseInput() throws IOException { + var processorInput = parseInputFile("/preprocessor/input.txt"); + + assertEquals(7, processorInput.getKeys().size()); + assertEquals("Value of the first key", processorInput.getValue("FIRST_KEY")); + assertEquals(" Value of the second key", processorInput.getValue("SECOND_KEY")); + assertEquals(" Value of the third key ", processorInput.getValue("THIRD_KEY")); + assertEquals("\\ Value of the fourth key \\", processorInput.getValue("FOURTH_KEY")); + assertEquals("\\\\Value of the fifth key\\\\", processorInput.getValue("5TH_KEY")); + assertEquals("\\", processorInput.getValue("6TH_KEY")); + assertEquals("\\\\", processorInput.getValue("7TH_KEY")); + } + + @Test + public void shouldThrowOnMalformedInput() { + makeInputExceptionCase("lowercase-key test value", InputConflict.INVALID_KEY_CHARACTERS); + makeInputExceptionCase("illègal_chärs test value", InputConflict.INVALID_KEY_CHARACTERS); + makeInputExceptionCase("NO_VALUE", InputConflict.BLANK_VALUE); + makeInputExceptionCase("EMPTY_VALUE ", InputConflict.BLANK_VALUE); + makeInputExceptionCase("BLANK_VALUE ", InputConflict.BLANK_VALUE); + } + + @Test + public void shouldSaveAsRead() throws Exception { + var filePath = "/preprocessor/input.txt"; + var processor = parseInputFile(filePath); + assertLinesEqual( + getFileContents(filePath), + captureOutput(processor::save) + ); + } + + @Test + public void shouldMigrate() throws Exception { + var inputProcessor = parseInputFile("/preprocessor/migration_base.txt"); + var extensionProcessor = parseInputFile("/preprocessor/migration_updated.txt"); + + assertEquals(6, inputProcessor.migrateTo(extensionProcessor)); + + assertLinesEqual( + getFileContents("/preprocessor/migration_result.txt"), + captureOutput(inputProcessor::save) + ); + } + + private void makeInputExceptionCase(String inputString, InputConflict expectedConflict) { + assertEquals( + expectedConflict, + assertThrows( + PreProcessorInputException.class, + () -> new PreProcessorInput().load(new StringReader(inputString)) + ).conflict + ); + } + + private PreProcessorInput parseInputFile(String filePath) throws IOException { + try ( + var inputStream = getClass().getResourceAsStream(filePath); + ) { + assert inputStream != null; + + var processorInput = new PreProcessorInput(); + processorInput.load(new InputStreamReader(inputStream)); + return processorInput; + } + } +} diff --git a/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTestBase.java b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTestBase.java new file mode 100644 index 0000000..31b025e --- /dev/null +++ b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTestBase.java @@ -0,0 +1,54 @@ +package me.blvckbytes.bbconfigmapper; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public abstract class PreProcessorTestBase { + + @FunctionalInterface + protected interface UnsafeConsumer { + void accept(T value) throws Exception; + } + + protected static final Logger logger = Logger.getAnonymousLogger(); + + protected String captureOutput(UnsafeConsumer handler) throws Exception { + var stringWriter = new StringWriter(); + handler.accept(stringWriter); + return stringWriter.toString(); + } + + protected String getFileContents(String filePath) throws IOException { + try ( + var inputStream = getClass().getResourceAsStream(filePath); + ) { + assert inputStream != null; + return new String(inputStream.readAllBytes()); + } + } + + protected void assertLinesEqual(String expected, String actual) { + try { + var expectedLines = expected.split("\n"); + var actualLines = actual.split("\n"); + var minLength = Math.min(expectedLines.length, actualLines.length); + + for (var i = 0; i < minLength; ++i) + assertEquals(expectedLines[i], actualLines[i], "Mismatch on line " + (i + 1)); + + if (actualLines.length != expectedLines.length) + throw new AssertionError("Expected " + actualLines.length + " lines but got " + expectedLines.length); + } catch (AssertionError error) { + System.out.println("----------------[Expected]----------------"); + System.out.println(expected); + System.out.println("----------------[Actual]----------------"); + System.out.println(actual); + + throw error; + } + } +} diff --git a/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTests.java b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTests.java new file mode 100644 index 0000000..be8bf0c --- /dev/null +++ b/src/test/java/me/blvckbytes/bbconfigmapper/PreProcessorTests.java @@ -0,0 +1,221 @@ +package me.blvckbytes.bbconfigmapper; + +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessConflict; +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessor; +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessorException; +import me.blvckbytes.bbconfigmapper.preprocessor.PreProcessorInput; +import org.junit.jupiter.api.Test; + +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class PreProcessorTests extends PreProcessorTestBase { + + @Test + public void shouldRenderInterpolations() throws Exception { + interpolationCase( + "\"Simple string without variables\"", + "Simple string without variables" + ); + + interpolationCase( + "\"Do not interpolate { spaced } brackets\"", + "Do not interpolate { spaced } brackets" + ); + + interpolationCase( + "\"One substituted \" & variable & \" :)\"", + "One substituted {variable} :)" + ); + + interpolationCase( + "\"One substituted\" & variable & \" :)\"", + "One substituted{variable} :)" + ); + + interpolationCase( + "\"One substituted \" & variable & \":)\"", + "One substituted {variable}:)" + ); + + interpolationCase( + "\"One substituted\" & variable & \":)\"", + "One substituted{variable}:)" + ); + + interpolationCase( + "variable & \" leading variable\"", + "{variable} leading variable" + ); + + interpolationCase( + "\"trailing variable \" & variable", + "trailing variable {variable}" + ); + + interpolationCase( + "\"unmatched {brackets stay {untouched \" & test", + "unmatched {brackets stay {untouched {test}" + ); + + interpolationCase( + "a & \"Multiple\" & b & \"Variables\" & c & \":)\"", + "{a}Multiple{b}Variables{c}:)" + ); + + interpolationCase( + "a & \"Multiple\" & lut[\"PREFIX\"] & \"Variables\" & c & \":)\"", + "{a}Multiple{b}Variables{c}:)", + Map.of( + "b", "lut[\"PREFIX\"]" + ) + ); + + interpolationCase( + "\"Escaped {variable} substitution\"", + "Escaped \\{variable} substitution" + ); + + interpolationCase( + "\"String containing \\\" double-quotes \\\"\"", + "String containing \" double-quotes \"" + ); + + interpolationCase( + "\"Unterminated {interpolation\"", + "Unterminated {interpolation" + ); + + interpolationCase( + "\"Ending with \" & variable", + "Ending with {variable}" + ); + } + + @Test + public void shouldParseTemporaryVariables() throws Exception { + var preProcessor = new PreProcessor(); + var temporaryVariables = preProcessor.parseTemporaryVariables( + "a=lut[\"PREFIX\"];B = test ; c =if x then y else z", + 0 + ); + + assertEquals(3, temporaryVariables.size()); + assertEquals("lut[\"PREFIX\"]", temporaryVariables.get("a")); + assertEquals("test", temporaryVariables.get("b")); + assertEquals("if x then y else z", temporaryVariables.get("c")); + } + + @Test + public void shouldThrowOnMalformedTemporaryVariables() { + assertEquals( + PreProcessConflict.MALFORMED_TEMPORARY_VARIABLE, + assertThrows( + PreProcessorException.class, + () -> new PreProcessor().parseTemporaryVariables("hello", 0) + ).conflict + ); + + assertEquals( + PreProcessConflict.MALFORMED_TEMPORARY_VARIABLE, + assertThrows( + PreProcessorException.class, + () -> new PreProcessor().parseTemporaryVariables("test=", 0) + ).conflict + ); + + assertEquals( + PreProcessConflict.MALFORMED_TEMPORARY_VARIABLE, + assertThrows( + PreProcessorException.class, + () -> new PreProcessor().parseTemporaryVariables("=test", 0) + ).conflict + ); + } + + @Test + public void shouldIterateScalarValues() throws Exception { + var config = new YamlConfig(null, logger, "$"); + + try ( + var inputStream = getClass().getResourceAsStream("/preprocessor/scalar_values.yml") + ) { + assert inputStream != null; + config.load(new InputStreamReader(inputStream)); + + var expectedKeys = new ArrayList<>(List.of( + "value 1", "value 2", "value 3", "value 4", "value 5", + "value 6", "value 7", "value 8", "value 9", "value 10" + )); + + var preProcessor = new PreProcessor(); + + preProcessor.forEachScalarValue(config, node -> { + assertTrue(expectedKeys.remove(node.getValue()), "Value " + node.getValue() + " was unexpected"); + }); + + assertEquals( + "", + String.join("; ", expectedKeys), + "There are remaining expected items which have not occurred" + ); + } + } + + @Test + public void shouldPreProcessComplexFile() throws Exception { + var config = new YamlConfig(null, logger, "$"); + + try ( + var inputStream = getClass().getResourceAsStream("/preprocessor/base.yml") + ) { + assert inputStream != null; + config.load(new InputStreamReader(inputStream)); + + var variablesPath = "/preprocessor/substitution_input.txt"; + var processorInput = new PreProcessorInput(); + var preProcessor = new PreProcessor(); + + try ( + var processorInputStream = getClass().getResourceAsStream(variablesPath) + ) { + assert processorInputStream != null; + processorInput.load(new InputStreamReader(processorInputStream)); + } + + preProcessor.forEachScalarValue(config, node -> { + var result = preProcessor.preProcess(node.getValue(), processorInput); + preProcessor.setScalarValue(node, result); + }); + + assertLinesEqual( + getFileContents("/preprocessor/result.yml"), + captureOutput(config::save) + ); + } + } + + @Test + public void shouldThrowOnUnknownVariables() throws Exception { + assertEquals( + PreProcessConflict.VARIABLE_NOT_FOUND, + assertThrows( + PreProcessorException.class, + () -> new PreProcessor().preProcess("@{UNKNOWN_VARIABLE}", new PreProcessorInput()) + ).conflict + ); + } + + private void interpolationCase(String expectedOutput, String input) throws Exception { + interpolationCase(expectedOutput, input, Map.of()); + } + + private void interpolationCase(String expectedOutput, String input, Map temporaryVariables) throws Exception { + var preProcessor = new PreProcessor(); + assertEquals(expectedOutput, preProcessor.renderInterpolations(input, temporaryVariables)); + } +} diff --git a/src/test/resources/preprocessor/base.yml b/src/test/resources/preprocessor/base.yml new file mode 100644 index 0000000..71e2979 --- /dev/null +++ b/src/test/resources/preprocessor/base.yml @@ -0,0 +1,9 @@ +simpleKey: '@{KEY_A}' +multilineValue$: | + hello world @{KEY_B} +multilineList: +- | + hello world + test + @{KEY_C} + another line diff --git a/src/test/resources/preprocessor/input.txt b/src/test/resources/preprocessor/input.txt new file mode 100644 index 0000000..2250054 --- /dev/null +++ b/src/test/resources/preprocessor/input.txt @@ -0,0 +1,17 @@ +# This is a comment + + FIRST_KEY Value of the first key + SECOND_KEY \ Value of the second key +# Comment attached to third key +THIRD_KEY \ Value of the third key \ + +# Dangling comment in the middle + + # Comment attached to fourth key +FOURTH_KEY \\ Value of the fourth key \\ + 5TH_KEY \\Value of the fifth key\\ + +6TH_KEY \ + 7TH_KEY \\ + +# Dangling comment at the end \ No newline at end of file diff --git a/src/test/resources/preprocessor/migration_base.txt b/src/test/resources/preprocessor/migration_base.txt new file mode 100644 index 0000000..fccd3ef --- /dev/null +++ b/src/test/resources/preprocessor/migration_base.txt @@ -0,0 +1,15 @@ +# This is key a +# - var_a: TypeA +# - var_b: TypeB +# - var_c: TypeC +KEY_A Value A + +# This is key b +# - var_a: TypeA +# - var_x: TypeX +KEY_B Value B +KEY_Y Value Y +KEY_DISCONTINUED This one will be left untouched + +# This is key c +KEY_C Value C \ No newline at end of file diff --git a/src/test/resources/preprocessor/migration_result.txt b/src/test/resources/preprocessor/migration_result.txt new file mode 100644 index 0000000..442a37f --- /dev/null +++ b/src/test/resources/preprocessor/migration_result.txt @@ -0,0 +1,25 @@ +KEY_VERY_TOP_1 Value very top 1 +KEY_VERY_TOP_2 Value very top 2 + +# This is key a +# - var_a: TypeA +# - var_b: TypeB +# - var_c: TypeC +# - var_d: TypeD +KEY_A Value A +KEY_NEAR_TO_A Value near to a + +# This is key b +# - var_b: TypeB +# - var_a: TypeUpdated +KEY_B Value B +KEY_NEAR_TO_B_1 Value near to b 1 +KEY_NEAR_TO_B_2 Value near to b 2 +KEY_Y Value Y +KEY_DISCONTINUED This one will be left untouched + +# This is key c +# - var_y: TypeY +KEY_C Value C + +KEY_AT_BOTTOM Value at bottom \ No newline at end of file diff --git a/src/test/resources/preprocessor/migration_updated.txt b/src/test/resources/preprocessor/migration_updated.txt new file mode 100644 index 0000000..c7a47ed --- /dev/null +++ b/src/test/resources/preprocessor/migration_updated.txt @@ -0,0 +1,24 @@ +KEY_VERY_TOP_1 Value very top 1 +KEY_VERY_TOP_2 Value very top 2 + +# This is key a +# - var_d: TypeD +# - var_a: TypeA +# - var_b: TypeB +# - var_c: TypeC +KEY_A Value A +KEY_NEAR_TO_A Value near to a + +# This is key b +# - var_b: TypeB +# - var_a: TypeUpdated +KEY_B Value B +KEY_NEAR_TO_B_1 Value near to b 1 +KEY_NEAR_TO_B_2 Value near to b 2 +KEY_Y Value Y + +# This is key c +# - var_y: TypeY +KEY_C Value C + +KEY_AT_BOTTOM Value at bottom diff --git a/src/test/resources/preprocessor/result.yml b/src/test/resources/preprocessor/result.yml new file mode 100644 index 0000000..436aefd --- /dev/null +++ b/src/test/resources/preprocessor/result.yml @@ -0,0 +1,9 @@ +simpleKey$: '"value a"' +multilineValue$: | + hello world "value b" +multilineList$: +- | + hello world + test + "before " & x & " after" + another line diff --git a/src/test/resources/preprocessor/scalar_values.yml b/src/test/resources/preprocessor/scalar_values.yml new file mode 100644 index 0000000..75698f0 --- /dev/null +++ b/src/test/resources/preprocessor/scalar_values.yml @@ -0,0 +1,15 @@ +simpleKey: value 1 +parent: + child: value 2 + anotherChild: + yetAnother: value 3 + listKey: + - value 4 + - value 5 + - value 6 + - + x: value 7 + y: value 8 +topList: + - value 9 + - value 10 diff --git a/src/test/resources/preprocessor/substitution_input.txt b/src/test/resources/preprocessor/substitution_input.txt new file mode 100644 index 0000000..b6a384d --- /dev/null +++ b/src/test/resources/preprocessor/substitution_input.txt @@ -0,0 +1,3 @@ +KEY_A value a +KEY_B value b +KEY_C before {x} after