From f38d232404ccd74649b86f71c6dcca01aee15e85 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 30 Sep 2026 20:16:54 +0200 Subject: [PATCH] fix: fixed implementation of the new navigation methods And added tests for them --- .gitignore | 1 + .../java/org/codejive/properties/Cursor.java | 11 +- .../org/codejive/properties/Properties.java | 69 ++++++++--- .../codejive/properties/TestProperties.java | 113 ++++++++++++++++++ 4 files changed, 177 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 92322c4..3d5af4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ +.vscode/ target/ diff --git a/src/main/java/org/codejive/properties/Cursor.java b/src/main/java/org/codejive/properties/Cursor.java index 1df13f5..6a9aba7 100644 --- a/src/main/java/org/codejive/properties/Cursor.java +++ b/src/main/java/org/codejive/properties/Cursor.java @@ -44,11 +44,11 @@ private Cursor(List tokens, int index) { } public boolean atStart() { - return index < 0; + return tokens.isEmpty() || index < 0; } public boolean atEnd() { - return index >= tokens.size(); + return tokens.isEmpty() || index >= tokens.size(); } public int position() { @@ -174,7 +174,12 @@ public int prevCount(Predicate accept) { * @return a Cursor pointing to the start of the line */ public Cursor home() { - prevWhile(tk -> !tk.isEol()); + if (atEnd()) { + return this; + } + while (index > 0 && !tokens.get(index - 1).isEol()) { + prev(); + } return this; } diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index 6fb9a6d..c05dcbe 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -437,17 +437,10 @@ private void replaceValue(String key, String rawValue, String value) { pos.replace(new PropertiesParser.Token(PropertiesParser.Type.VALUE, rawValue, value)); } - // Add new tokens to the end of the list of tokens + // Add a new property to the end of the list of tokens private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String value) { Cursor pos = afterLastProperty(); - // Add a newline whitespace token if necessary - if (!pos.atStart()) { - if (pos.isEol()) { - pos.next().addEol(eolType).prev(); - } else { - pos.addEol(eolType); - } - } else { + if (pos.atStart()) { // We're at the start, meaning there are no properties yet, // but there might be comments, so we move forward again, // skipping any header comments @@ -472,6 +465,20 @@ private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String } } } + return addNewKeyValue(pos, rawKey, key, rawValue, value); + } + + // Add a new property at the given position in the list of tokens + // NB: this method will make sure to move the cursor to a legal insertion point + private Cursor addNewKeyValue( + Cursor pos, String rawKey, String key, String rawValue, String value) { + if (pos == null) { + pos = afterLastProperty(); + } + + // Add a newline whitespace token if necessary + pos = prepareNewLine(pos); + // Add tokens for key, separator and value pos.add(new PropertiesParser.Token(PropertiesParser.Type.KEY, rawKey, key)); pos.add(new PropertiesParser.Token(PropertiesParser.Type.SEPARATOR, "=")); @@ -479,6 +486,34 @@ private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String return pos; } + // Prepare the list for adding a new line at the given position. It will make sure the cursor + // is at a valid insertion point and that any required EOLs are added. + private Cursor prepareNewLine(Cursor pos) { + if (!isEmpty()) { + if (pos.atEnd()) { + // We're at the end of the list and we want to maintain the current structure. + // Meaning that if currently the last token is an EOL, we want to maintain that + // and make sure that after adding a new line, we still end with an EOL. But if + // there is no EOL, we add one because we need a separation between the last line + // and the new line, but we won't add an extra EOL at the end. + if (pos.copy().prev().isEol()) { + pos.addEol(eolType).prev(); + } else { + pos.addEol(eolType); + } + } else if (pos.atStart()) { + // If we're at the start we always add a new line at the beginning. Even if there + // already was an EOL, because if it was there it was most likely meant as spacing. + pos.addEol(eolType).prev(); + } else { + // If we're at any other position, we add a new line at the beginning of the line + // to maintain separation and move our insertion point right in front of it. + pos.home().addEol(eolType).prev(); + } + } + return pos; + } + @Override public String remove(Object key) { String skey = key.toString(); @@ -1054,8 +1089,8 @@ public Cursor beforeProperty(String key) { Cursor pos = first().nextWhile( tk -> - tk.getType() == PropertiesParser.Type.KEY - && !tk.getText().equals(key)); + tk.getType() != PropertiesParser.Type.KEY + || !tk.getText().equals(key)); if (pos.atEnd()) { return null; } else { @@ -1075,11 +1110,17 @@ public Cursor afterProperty(String key) { Cursor pos = last().prevWhile( tk -> - tk.getType() == PropertiesParser.Type.KEY - && !tk.getText().equals(key)); + tk.getType() != PropertiesParser.Type.KEY + || !tk.getText().equals(key)); if (pos.atStart()) { return null; } else { + // Skip over the separator and the value + pos.next(); + validate(pos.isType(PropertiesParser.Type.SEPARATOR), pos); + pos.next(); + validate(pos.isType(PropertiesParser.Type.VALUE), pos); + // Skip over the end-of-line if it exists if (pos.next().isEol()) { pos.next(); } @@ -1121,7 +1162,7 @@ public Cursor afterLastProperty() { // Make sure we're either at the start or we've found a property validate(pos.atStart() || pos.isType(PropertiesParser.Type.VALUE), pos); if (!pos.atStart()) { - pos.next(); + pos.next().nextIf(Token::isEol); } return pos; } diff --git a/src/test/java/org/codejive/properties/TestProperties.java b/src/test/java/org/codejive/properties/TestProperties.java index d69915c..8fe4119 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -10,8 +10,10 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.AbstractMap; +import java.util.Arrays; import java.util.Collections; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import org.junit.jupiter.api.Test; @@ -1109,6 +1111,101 @@ void testUnescaped() throws IOException, URISyntaxException { assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-unescaped.properties"))); } + @Test + void testBeforeProperty() throws IOException { + Properties p = + Properties.loadProperties( + new StringReader("# header\n\n # attached\n alpha=1\nbeta=2\n")); + Cursor beforeAlpha = p.beforeProperty("alpha"); + assertThat(beforeAlpha).isNotNull(); + assertThat(beforeAlpha.isWhitespace()).isTrue(); + assertThat(beforeAlpha.raw()).isEqualTo(" "); + + Cursor beforeBeta = p.beforeProperty("beta"); + assertThat(beforeBeta).isNotNull(); + assertThat(beforeBeta.isType(PropertiesParser.Type.KEY)).isTrue(); + assertThat(beforeBeta.text()).isEqualTo("beta"); + + Properties noLeadingContent = Properties.loadProperties(new StringReader("alpha=1\n")); + assertThat(noLeadingContent.beforeProperty("alpha").position()).isZero(); + assertThat(p.beforeProperty("missing")).isNull(); + assertThat(new Properties().beforeProperty("missing")).isNull(); + + Properties duplicate = Properties.loadProperties(new StringReader("alpha=1\nalpha=2\n")); + assertThat(duplicate.beforeProperty("alpha").position()).isZero(); + } + + @Test + void testAfterProperty() throws IOException { + Properties p = Properties.loadProperties(new StringReader("alpha=1\nbeta=2\n# trailer\n")); + Cursor afterAlpha = p.afterProperty("alpha"); + assertThat(afterAlpha).isNotNull(); + assertThat(afterAlpha.isType(PropertiesParser.Type.KEY)).isTrue(); + assertThat(afterAlpha.text()).isEqualTo("beta"); + + Cursor afterBeta = p.afterProperty("beta"); + assertThat(afterBeta).isNotNull(); + assertThat(afterBeta.isType(PropertiesParser.Type.COMMENT)).isTrue(); + assertThat(afterBeta.raw()).isEqualTo("# trailer"); + + Properties noFinalEol = Properties.loadProperties(new StringReader("alpha=1")); + assertThat(noFinalEol.afterProperty("alpha").atEnd()).isTrue(); + + Properties keyOnly = Properties.loadProperties(new StringReader("alpha\nbeta=value")); + assertThat(keyOnly.afterProperty("alpha").text()).isEqualTo("beta"); + assertThat(keyOnly.afterProperty("missing")).isNull(); + assertThat(new Properties().afterProperty("missing")).isNull(); + + Properties crlf = Properties.loadProperties(new StringReader("alpha=1\r\nbeta=2\r\n")); + assertThat(crlf.afterProperty("alpha").text()).isEqualTo("beta"); + + Properties duplicate = + Properties.loadProperties(new StringReader("alpha=1\nbeta=2\nalpha=3")); + assertThat(duplicate.afterProperty("alpha").atEnd()).isTrue(); + } + + @Test + void testBeforeFirstProperty() throws IOException { + Properties withHeader = + Properties.loadProperties( + new StringReader("# header\n\n # attached\n alpha=1\nbeta=2\n")); + Cursor beforeFirst = withHeader.beforeFirstProperty(); + assertThat(beforeFirst.isWhitespace()).isTrue(); + assertThat(beforeFirst.raw()).isEqualTo(" "); + + Properties withoutHeader = Properties.loadProperties(new StringReader("alpha=1\nbeta=2")); + assertThat(withoutHeader.beforeFirstProperty().atStart()).isTrue(); + + Properties commentsOnly = + Properties.loadProperties(new StringReader("# header\n\n# another\n")); + assertThat(commentsOnly.beforeFirstProperty().atEnd()).isTrue(); + assertThat(new Properties().beforeFirstProperty().atStart()).isTrue(); + } + + @Test + void testAfterLastProperty() throws IOException { + Properties withTrailer = + Properties.loadProperties( + new StringReader("# header\nalpha=1\nbeta=2\n# trailer\n")); + Cursor afterLast = withTrailer.afterLastProperty(); + assertThat(afterLast.isType(PropertiesParser.Type.COMMENT)).isTrue(); + assertThat(afterLast.raw()).isEqualTo("# trailer"); + + Properties withBlankTrailer = + Properties.loadProperties(new StringReader("alpha=1\n\n# trailer\n")); + assertThat(withBlankTrailer.afterLastProperty().isEol()).isTrue(); + + Properties noFinalEol = Properties.loadProperties(new StringReader("alpha=1")); + assertThat(noFinalEol.afterLastProperty().atEnd()).isTrue(); + + Properties keyOnly = Properties.loadProperties(new StringReader("alpha\n")); + assertThat(keyOnly.afterLastProperty().atEnd()).isTrue(); + + Properties commentsOnly = Properties.loadProperties(new StringReader("# header\n\n")); + assertThat(commentsOnly.afterLastProperty().atStart()).isTrue(); + assertThat(new Properties().afterLastProperty().atStart()).isTrue(); + } + @Test void testCursor() throws IOException, URISyntaxException { Properties p = Properties.loadProperties(getResource("/test.properties")); @@ -1118,6 +1215,22 @@ void testCursor() throws IOException, URISyntaxException { assertThat(c.prevCount(t -> true)).isEqualTo(p.last().position() + 1); } + @Test + void testCursorHome() { + List tokens = + Arrays.asList( + new PropertiesParser.Token(PropertiesParser.Type.KEY, "first"), + new PropertiesParser.Token(PropertiesParser.Type.WHITESPACE, "\n"), + new PropertiesParser.Token(PropertiesParser.Type.KEY, "second"), + new PropertiesParser.Token(PropertiesParser.Type.SEPARATOR, "="), + new PropertiesParser.Token(PropertiesParser.Type.VALUE, "value")); + + assertThat(Cursor.first(tokens).home().position()).isZero(); + assertThat(Cursor.index(tokens, 4).home().position()).isEqualTo(2); + assertThat(Cursor.index(tokens, 2).home().position()).isEqualTo(2); + assertThat(Cursor.last(tokens).next().home().position()).isEqualTo(tokens.size()); + } + @Test void testMissingDelim() throws IOException, URISyntaxException { Properties p = Properties.loadProperties(getResource("/test-missingdelim.properties"));