From 088456c524a6dab713f43d3d3b9e3e76765a23b1 Mon Sep 17 00:00:00 2001 From: "eric.jarosch" Date: Mon, 21 Sep 2026 02:13:59 +0200 Subject: [PATCH 1/3] Fix #56 --- .../org/codejive/properties/Properties.java | 29 +++++++++++++++---- .../codejive/properties/PropertiesParser.java | 12 ++++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index 5b2e9e5..5692096 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -617,14 +617,23 @@ private List findPropertyCommentLines(Cursor pos) { return Collections.unmodifiableList(result); } - private Cursor indexOf(String key) { + /** + * @param key the key of the property whose token is searched. + * @return a Cursor pointing to the {@link PropertiesParser.Type#KEY} token of the property. + *
Or a Cursor pointing to {@code -1} if no such property exists. + */ + public Cursor indexOf(String key) { return index( tokens.indexOf( new PropertiesParser.Token( PropertiesParser.Type.KEY, escapeKey(key), key))); } - private static String escapeValue(String value) { + /** + * @param value the value to escape + * @return the value, escaped such that it can be stored in a .properties file + */ + public static String escapeValue(String value) { return value.replace("\\", "\\\\") .replace("\n", "\\n") .replace("\r", "\\r") @@ -632,7 +641,11 @@ private static String escapeValue(String value) { .replace("\f", "\\f"); } - private static String escapeKey(String key) { + /** + * @param key the key to escape + * @return the key, escaped such that it can be stored in a .properties file + */ + public static String escapeKey(String key) { return escapeValue(key).replace(" ", "\\ "); } @@ -997,11 +1010,17 @@ Cursor index(int index) { return Cursor.index(tokens, index); } - Cursor first() { + /** + * @return a Cursor pointing to the first token, or to {@code -1} if no tokens have been loaded. + */ + public Cursor first() { return Cursor.first(tokens); } - Cursor last() { + /** + * @return a Cursor pointing to the last token, or to {@code -1} if no tokens have been loaded. + */ + public Cursor last() { return Cursor.last(tokens); } diff --git a/src/main/java/org/codejive/properties/PropertiesParser.java b/src/main/java/org/codejive/properties/PropertiesParser.java index aada459..c43ec85 100644 --- a/src/main/java/org/codejive/properties/PropertiesParser.java +++ b/src/main/java/org/codejive/properties/PropertiesParser.java @@ -17,7 +17,7 @@ * input which makes it possible to exactly recreate the original, including all whitespace and * comments. */ -class PropertiesParser { +public class PropertiesParser { /** The type of token. */ public enum Type { @@ -59,7 +59,7 @@ public static class Token { * @param type The token's type * @param raw The token's value */ - Token(Type type, String raw) { + public Token(Type type, String raw) { this(type, raw, null); } @@ -71,7 +71,7 @@ public static class Token { * @param raw The token's raw value (including escape sequences) * @param text The token's processed value (no escape sequences) */ - Token(Type type, String raw, String text) { + public Token(Type type, String raw, String text) { this.type = type; this.raw = raw; if (raw.equals(text)) { @@ -310,7 +310,7 @@ private String string() { * @param escape Input string * @return Decoded string */ - static String unescape(String escape) { + public static String unescape(String escape) { StringBuilder txt = new StringBuilder(); for (int i = 0; i < escape.length(); i++) { char ch = escape.charAt(i); @@ -358,7 +358,7 @@ && isWhitespaceChar(ch = escape.charAt(i + 1))) { return txt.toString(); } - private static boolean isSeparatorChar(int ch) { + public static boolean isSeparatorChar(int ch) { return ch == '=' || ch == ':'; } @@ -370,7 +370,7 @@ private static boolean isWhitespaceEolChar(int ch) { return isWhitespaceChar(ch) || isEol(ch); } - private static boolean isCommentChar(int ch) { + public static boolean isCommentChar(int ch) { return ch == '#' || ch == '!'; } From e3f23d63fc8b0298226f1f2810e80991313c7fff Mon Sep 17 00:00:00 2001 From: "eric.jarosch" Date: Tue, 22 Sep 2026 15:39:05 +0200 Subject: [PATCH 2/3] Fix failing tests and some inspections/warnings --- .../java/org/codejive/properties/TestProperties.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/codejive/properties/TestProperties.java b/src/test/java/org/codejive/properties/TestProperties.java index 79498f7..f39d0c6 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -4,6 +4,7 @@ import java.io.*; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -62,6 +63,7 @@ void testLoad() throws IOException, URISyntaxException { new AbstractMap.SimpleEntry<>("key.4", "\\u1234\u1234")); } + @Test void testLoadCrLf() throws IOException, URISyntaxException { Properties p = Properties.loadProperties(getResource("/testcrlf.properties")); assertThat(p).size().isEqualTo(7); @@ -87,7 +89,7 @@ void testLoadCrLf() throws IOException, URISyntaxException { "and escapes\\n\\t\\r\\f", "everywhere ", "value", - "one \\\n two \\\n\tthree", + "one \\\r\n two \\\r\n\tthree", "\\u1234\u1234"); assertThat(p.entrySet()) .containsExactly( @@ -105,7 +107,7 @@ void testLoadCrLf() throws IOException, URISyntaxException { new AbstractMap.SimpleEntry<>("three", "and escapes\\n\\t\\r\\f"), new AbstractMap.SimpleEntry<>("\\ with\\ spaces", "everywhere "), new AbstractMap.SimpleEntry<>("altsep", "value"), - new AbstractMap.SimpleEntry<>("multiline", "one \\\n two \\\n\tthree"), + new AbstractMap.SimpleEntry<>("multiline", "one \\\r\n two \\\r\n\tthree"), new AbstractMap.SimpleEntry<>("key.4", "\\u1234\u1234")); } @@ -553,7 +555,7 @@ void testRemoveNonExistent() throws IOException, URISyntaxException { @Test void testRemoveMiddleIterator() throws IOException, URISyntaxException { Properties p = Properties.loadProperties(getResource("/test.properties")); - Iterator iter = p.keySet().iterator(); + Iterator iter = p.keySet().iterator(); while (iter.hasNext()) { if (iter.next().equals("three")) { iter.remove(); @@ -622,7 +624,7 @@ void testInteropStore() throws IOException, URISyntaxException { } @Test - void testInteropPutLoad() throws IOException, URISyntaxException { + void testInteropPutLoad() throws IOException { java.util.Properties p = new java.util.Properties(); p.put("one", "simple"); p.put("two", "value containing spaces"); @@ -718,6 +720,6 @@ private Path getResource(String name) throws URISyntaxException { } private String readAll(Path f) throws IOException { - return new String(Files.readAllBytes(f)); + return new String(Files.readAllBytes(f), StandardCharsets.UTF_8); } } From d5835143c331de1ec8b39235d6b651ee3ac3297b Mon Sep 17 00:00:00 2001 From: "eric.jarosch" Date: Tue, 22 Sep 2026 20:07:10 +0200 Subject: [PATCH 3/3] Fixes when dealing with unusual properties files and other edge-cases. - Properties#put no longer crashes when the last property is at the end of the file and has no value - Properties#put inserts the correct amount of blank lines when the file contains no properties but some number of comments. - Properties#load no longer swallows the last key if it has no value. - Cursor#add skips the inserted item when there are no tokens (matching the behaviour seen when there are tokens) - Cursor#addToken now uses the correct index when checking Cursor#hasToken. Thus the line is inserted in the expected position, instead of at the end of the file. --- .../java/org/codejive/properties/Cursor.java | 31 ++++- .../org/codejive/properties/Properties.java | 24 ++-- .../org/codejive/properties/TestCursor.java | 57 ++++++++ .../codejive/properties/TestProperties.java | 128 ++++++++++++++++++ 4 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src/test/java/org/codejive/properties/TestCursor.java diff --git a/src/main/java/org/codejive/properties/Cursor.java b/src/main/java/org/codejive/properties/Cursor.java index 0da8375..5f47d95 100644 --- a/src/main/java/org/codejive/properties/Cursor.java +++ b/src/main/java/org/codejive/properties/Cursor.java @@ -24,15 +24,29 @@ private Cursor(List tokens, int index) { this.index = index; } + /** + * @return {@code true} if the Cursor is at the start of the document, before the first token. + */ public boolean atStart() { return index < 0; } + /** + * @return {@code true} if the Cursor is at the end of the document, after the last token. + */ + public boolean atEnd() { + return index >= tokens.size(); + } + public int position() { return index; } public boolean hasToken() { + return hasToken(index); + } + + private boolean hasToken(int index) { return index >= 0 && index < tokens.size(); } @@ -141,17 +155,32 @@ public int prevCount(Predicate accept) { return cnt; } + /** + * Inserts a token at the current position, pushing the current token (if any) forwards. + *
+ * This method advances the cursor by one token (causing it to point at the initial token again). + * @param token the token to insert. + * @return {@code this} + */ public Cursor add(PropertiesParser.Token token) { + if (index < 0) + index = 0; + addToken(index++, token); return this; } + /** + * Inserts an EOL Token at the current position. + * @see #add(PropertiesParser.Token) + * @return {@code this} + */ public Cursor addEol() { return add(PropertiesParser.Token.EOL); } private void addToken(int index, PropertiesParser.Token token) { - if (hasToken()) { + if (hasToken(index)) { tokens.add(index, token); } else { tokens.add(token); diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index 670a3fe..342efc8 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -393,16 +393,12 @@ private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String while (pos.isType(PropertiesParser.Type.WHITESPACE, PropertiesParser.Type.COMMENT)) { pos.prev(); } - // Make sure we're either at the start or we've found a VALUE - validate(pos.atStart() || pos.isType(PropertiesParser.Type.VALUE), pos); + // Make sure we're either at the start or we've found a property + validate(pos.atStart() || pos.isType(PropertiesParser.Type.VALUE, PropertiesParser.Type.SEPARATOR, PropertiesParser.Type.KEY), pos); // Add a newline whitespace token if necessary if (pos.hasToken()) { pos.next(); - if (pos.isEol()) { - pos.next().addEol().prev(); - } else { - pos.addEol(); - } + pos.addEol(); } else { // We're at the start, meaning there are no properties yet, // but there might be comments, so we move forward again, @@ -410,10 +406,17 @@ private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String pos = skipHeaderCommentLines(); if (pos.position() > 0) { // We have to make sure there are at least 2 EOLs after the last comment + pos.prev(); // move cursor back onto the last eol (otherwise prevCount fails) int eols = pos.prevCount(t -> t.isEol()); - for (int i = 0; i < 2 - eols; i++) { + pos.skip(eols); // return to the position from before 'prevCount' was called + pos.next(); // move cursor past the last eol + int numEolsToAdd = Math.max(0, 2 - eols); + for (int i = 0; i < numEolsToAdd; i++) { pos.addEol(); } + // if there is another comment following this token, push it to a new line below this property. + if (!pos.atEnd()) + pos.addEol().prev(); } } // Add tokens for key, separator and value @@ -858,11 +861,16 @@ private Properties load(List ts) { String key = null; for (PropertiesParser.Token token : tokens) { if (token.type == PropertiesParser.Type.KEY) { + if (key != null) + values.put(key, ""); key = token.getText(); } else if (token.type == PropertiesParser.Type.VALUE) { values.put(key, token.getText()); + key = null; } } + if (key != null) + values.put(key, ""); return this; } diff --git a/src/test/java/org/codejive/properties/TestCursor.java b/src/test/java/org/codejive/properties/TestCursor.java new file mode 100644 index 0000000..f41846e --- /dev/null +++ b/src/test/java/org/codejive/properties/TestCursor.java @@ -0,0 +1,57 @@ +package org.codejive.properties; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; + +public class TestCursor { + @Test + void addTokenToEmptyDocument() throws IOException { + Properties p = Properties.loadProperties(new StringReader("")); + Cursor c = p.first(); + + // expect no tokens to be in the document + assertThat(c.hasToken()).isEqualTo(false); + + // expect that add should not throw + PropertiesParser.Token addedToken = new PropertiesParser.Token(PropertiesParser.Type.KEY, "test", "test"); + c.add(addedToken); + + // expect that add skips the added token + assertThat(c.hasToken()).isEqualTo(false); + c.prev(); + assertThat(c.hasToken()).isEqualTo(true); + assertThat(c.token()).isEqualTo(addedToken); + } + + @Test + void addTokensToDocument() throws IOException { + Properties p = Properties.loadProperties(new StringReader("key=value" + + "\nkey2=value2")); + + Cursor c = p.first(); + c.add(new PropertiesParser.Token(PropertiesParser.Type.COMMENT, "# beginning")); + c.addEol(); + + c = p.indexOf("key2"); + c.add(new PropertiesParser.Token(PropertiesParser.Type.COMMENT, "# middle")); + c.addEol(); + + c = p.last(); + c.next(); + c.addEol(); + c.add(new PropertiesParser.Token(PropertiesParser.Type.COMMENT, "# end")); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + p.store(os); + assertThat(os.toString()).isEqualTo("# beginning" + + "\nkey=value" + + "\n# middle" + + "\nkey2=value2" + + "\n# end"); + } +} diff --git a/src/test/java/org/codejive/properties/TestProperties.java b/src/test/java/org/codejive/properties/TestProperties.java index f39d0c6..36505f1 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -469,6 +469,55 @@ void testPutFirstWithHeader() throws IOException, URISyntaxException { } } + @Test + void testPutFirstWithHeader1Eol() throws IOException, URISyntaxException { + try (StringReader sr = new StringReader("# A header comment\n")) { + Properties p = Properties.loadProperties(sr); + p.put("first", "dummy"); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()) + .isEqualTo(readAll(getResource("/test-putfirstwithheader.properties"))); + } + } + + @Test + void testPutFirstWithHeader2Eol() throws IOException, URISyntaxException { + try (StringReader sr = new StringReader("# A header comment\n\n")) { + Properties p = Properties.loadProperties(sr); + p.put("first", "dummy"); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()) + .isEqualTo(readAll(getResource("/test-putfirstwithheader.properties"))); + } + } + + @Test + void testPutFirstWithHeader3Eol() throws IOException, URISyntaxException { + String expected = "# A header comment\n\n\nfirst=dummy"; + try (StringReader sr = new StringReader("# A header comment\n\n\n")) { + Properties p = Properties.loadProperties(sr); + p.put("first", "dummy"); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo(expected); + } + } + + @Test + void testPutFirstWithHeaderAndTrailer() throws IOException, URISyntaxException { + String given = "# A header comment\n\n\n# A trailer\n"; + String expected = "# A header comment\n\n\nfirst=dummy\n# A trailer\n"; + try (StringReader sr = new StringReader(given)) { + Properties p = Properties.loadProperties(sr); + p.put("first", "dummy"); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo(expected); + } + } + @Test void testPutNull() throws IOException, URISyntaxException { Properties p = new Properties(); @@ -715,6 +764,85 @@ void testPutAll() { assertThat(p.getProperty("foo")).isEqualTo("bar"); } + @Test + void testLoadEmptyValue() throws IOException { + String document = "firstline=\n" + + "secondline="; + Properties p = Properties.loadProperties(new StringReader(document)); + java.util.Properties ju = new java.util.Properties(); + ju.load(new StringReader(document)); + assertThat(p.asJUProperties()).isEqualTo(ju); + + // also verify that put works + p.put("thirdline", ""); + ju.put("thirdline", ""); + assertThat(p.asJUProperties()).isEqualTo(ju); + + // verify store + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo("firstline=\n" + + "secondline=\n" + + "thirdline="); + } + + @Test + void testLoadMissingSeparator() throws IOException { + String document = "firstline\n" + + "secondline"; + Properties p = Properties.loadProperties(new StringReader(document)); + java.util.Properties ju = new java.util.Properties(); + ju.load(new StringReader(document)); + assertThat(p.asJUProperties()).isEqualTo(ju); + + // also verify that put works + p.put("thirdline", ""); + ju.put("thirdline", ""); + assertThat(p.asJUProperties()).isEqualTo(ju); + + // verify store + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo("firstline\n" + + "secondline\n" + + "thirdline="); + } + + @Test + void testPutTrailingSpace() throws IOException { + String document = "foo=x \n"; + Properties p = Properties.loadProperties(new StringReader(document)); + p.put("bar", ""); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo("foo=x \n" + + "bar=\n"); + } + + @Test + void testPutMissingSeparatorTrailingSpace() throws IOException { + String document = "foo \n"; + Properties p = Properties.loadProperties(new StringReader(document)); + p.put("bar", ""); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo("foo \n" + + "bar=\n"); + } + + @Test + void testPutMissingSeparatorTrailingComment() throws IOException { + String document = "foo\n" + + "# trailer"; + Properties p = Properties.loadProperties(new StringReader(document)); + p.put("bar", ""); + StringWriter sw = new StringWriter(); + p.store(sw); + assertThat(sw.toString()).isEqualTo("foo\n" + + "bar=\n" + + "# trailer"); + } + private Path getResource(String name) throws URISyntaxException { return Paths.get(getClass().getResource(name).toURI()); }