From 8014797eef3f5a36182431bb24a0bafb7e2fb4fc Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Thu, 1 Oct 2026 20:07:07 +0200 Subject: [PATCH 1/2] fix: preserve header spacing when inserting properties (#88) --- .../org/codejive/properties/Properties.java | 37 ++++++------ .../codejive/properties/TestProperties.java | 56 ++++++++++++++++++- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index f26d9d1..ffebaf2 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -488,24 +488,17 @@ private Cursor addNewKeyValue(String rawKey, String key, String rawValue, String // 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 - // (*) = we'll always skip past the final comment's EOL pos = afterHeader(); - if (!pos.atStart()) { - // We have to make sure there are at least 2 EOLs after the last comment - if (pos.atEnd()) { - Cursor pp = last(); - if (pp.isType(PropertiesParser.Type.COMMENT)) { - // If the last token is a comment, we're short two EOLs - pos.addEol(eolType); - pos.addEol(eolType); - } else if (pp.isEol()) { - // If the last element is an EOL we still need one (*) - pos.addEol(eolType); - } - } else if (pos.isEol()) { - // If the current element is an EOL we know there are at least 2 (*), - // so we can simply move past it - pos.next(); + if (pos.atEnd()) { + // afterHeader() leaves at most one blank line after the last comment. + // Add only the missing line endings needed to keep the header free. + Cursor previous = pos.copy().prev(); + if (previous.isType(PropertiesParser.Type.COMMENT)) { + pos.addEol(eolType); + pos.addEol(eolType); + } else if (previous.isEol() + && previous.prev().isType(PropertiesParser.Type.COMMENT)) { + pos.addEol(eolType); } } } @@ -1186,7 +1179,8 @@ public Cursor beforeFirstProperty() { // Make sure we're either at the end or we've found a property validate(pos.atEnd() || pos.isType(PropertiesParser.Type.KEY), pos); if (!pos.atEnd()) { - pos.prev(); + List comments = findPropertyCommentLines(pos); + pos = comments.isEmpty() ? pos.home() : index(comments.get(0)).home(); } return pos; } @@ -1212,8 +1206,9 @@ public Cursor afterLastProperty() { } /** - * Returns a Cursor pointing to the position right after the last comment in the header. If no - * header comments are found, the cursor will point to the start of the file. + * Returns a Cursor pointing to the position after the last comment in the header, leaving a + * single empty separating line in between (if it exists). If no header comments are found, the + * cursor will be at the start of the list. * * @return a Cursor pointing to the right position */ @@ -1233,6 +1228,8 @@ public Cursor afterHeader() { pos = first(); } else { pos.home(); + // Leave at most one empty line between the header and the cursor. + pos.nextIf(PropertiesParser.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 84b890e..b06d128 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -1195,11 +1195,12 @@ void testBeforeFirstProperty() throws IOException { Properties.loadProperties( new StringReader("# header\n\n # attached\n alpha=1\nbeta=2\n")); Cursor beforeFirst = withHeader.beforeFirstProperty(); - assertThat(beforeFirst.isWhitespace()).isTrue(); + assertThat(beforeFirst.position()) + .isEqualTo(withHeader.beforeProperty("alpha").position() - 3); assertThat(beforeFirst.raw()).isEqualTo(" "); Properties withoutHeader = Properties.loadProperties(new StringReader("alpha=1\nbeta=2")); - assertThat(withoutHeader.beforeFirstProperty().atStart()).isTrue(); + assertThat(withoutHeader.beforeFirstProperty().position()).isZero(); Properties commentsOnly = Properties.loadProperties(new StringReader("# header\n\n# another\n")); @@ -1312,6 +1313,57 @@ void testPutReplaceMissingSepOrValue() throws IOException, URISyntaxException { assertThat(sw.toString()).isEqualTo(expected); } + @Test + void testPutBeforeFirstProperty() throws IOException { + final String given = "# header comment\n" + "\n" + "key=val"; + final String expected = "# header comment\n" + "\n" + "put=putVal\n" + "key=val"; + Properties p = Properties.loadProperties(new StringReader(given)); + p.put(p.beforeFirstProperty(), "put", "putVal"); + expectStoreText(p, expected); + } + + @Test + void testPutBeforeFirstPropertyWithComment() throws IOException { + final String given = "# header comment\n" + "\n" + "# key comment\n" + "key=val"; + final String expected = + "# header comment\n" + "\n" + "put=putVal\n" + "# key comment\n" + "key=val"; + Properties p = Properties.loadProperties(new StringReader(given)); + p.put(p.beforeFirstProperty(), "put", "putVal"); + expectStoreText(p, expected); + } + + @Test + void testPutAfterHeader() throws IOException { + final String given = "# header comment\n" + "\n" + "key=val"; + final String expected = "# header comment\n" + "\n" + "put=putVal\n" + "key=val"; + Properties p = Properties.loadProperties(new StringReader(given)); + p.put(p.afterHeader(), "put", "putVal"); + expectStoreText(p, expected); + } + + @Test + void testAfterHeaderLeavesAtMostOneBlankLine() throws IOException { + for (String eol : Arrays.asList("\n", "\r\n")) { + String input = "# header"; + for (int lineEndings = 0; lineEndings <= 3; lineEndings++) { + Properties p = Properties.loadProperties(new StringReader(input)); + assertThat(p.afterHeader().position()).isEqualTo(1 + Math.min(lineEndings, 2)); + if (lineEndings == 3) { + assertThat(p.afterHeader().isEol()).isTrue(); + } else { + assertThat(p.afterHeader().atEnd()).isTrue(); + } + input += eol; + } + } + } + + private void expectStoreText(Properties props, String expectedText) throws IOException { + StringWriter sw = new StringWriter(); + props.store(sw); + assertThat(sw.toString()).isEqualTo(expectedText); + } + private Path getResource(String name) throws URISyntaxException { URL resource = getClass().getResource(name); if (resource == null) From a3e7962955870081637b4c25185a676e9170a4d0 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Thu, 1 Oct 2026 20:10:38 +0200 Subject: [PATCH 2/2] fix: insert before attached comments on indented properties (#89) --- .../java/org/codejive/properties/Properties.java | 14 +++++++++----- .../org/codejive/properties/TestProperties.java | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index ffebaf2..a752b14 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -1116,8 +1116,8 @@ public Cursor last() { /** * Returns a Cursor pointing to the position right before the property with the given key. If no * such property exists, {@code null} will be returned. If the property was found the position - * will take into account any leading whitespace and will be positioned at the start of the - * line. + * will take into account any attached comments and leading whitespace and will be positioned at + * the start of the first attached comment line, or the property line if there are no comments. * * @param key The name of property to look for * @return a Cursor pointing to the right position or {@code null} if not found @@ -1131,10 +1131,15 @@ public Cursor beforeProperty(String key) { if (pos.atEnd()) { return null; } else { - return pos.home(); + return beforePropertyComments(pos); } } + private Cursor beforePropertyComments(Cursor pos) { + List comments = findPropertyCommentLines(pos); + return comments.isEmpty() ? pos.home() : index(comments.get(0)).home(); + } + /** * Returns a Cursor pointing to the position right after the property with the given key. If no * such property exists, {@code null} will be returned. If the property was found, the position @@ -1179,8 +1184,7 @@ public Cursor beforeFirstProperty() { // Make sure we're either at the end or we've found a property validate(pos.atEnd() || pos.isType(PropertiesParser.Type.KEY), pos); if (!pos.atEnd()) { - List comments = findPropertyCommentLines(pos); - pos = comments.isEmpty() ? pos.home() : index(comments.get(0)).home(); + pos = beforePropertyComments(pos); } return pos; } diff --git a/src/test/java/org/codejive/properties/TestProperties.java b/src/test/java/org/codejive/properties/TestProperties.java index b06d128..74ae1b2 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -1195,8 +1195,7 @@ void testBeforeFirstProperty() throws IOException { Properties.loadProperties( new StringReader("# header\n\n # attached\n alpha=1\nbeta=2\n")); Cursor beforeFirst = withHeader.beforeFirstProperty(); - assertThat(beforeFirst.position()) - .isEqualTo(withHeader.beforeProperty("alpha").position() - 3); + assertThat(beforeFirst.position()).isEqualTo(withHeader.beforeProperty("alpha").position()); assertThat(beforeFirst.raw()).isEqualTo(" "); Properties withoutHeader = Properties.loadProperties(new StringReader("alpha=1\nbeta=2")); @@ -1358,6 +1357,18 @@ void testAfterHeaderLeavesAtMostOneBlankLine() throws IOException { } } + @Test + void testPutBeforeIndentedProperty() throws IOException { + final String given = "# header comment\n" + "\n" + " # key comment\n" + " key=val"; + final String expected = + "# header comment\n" + "\n" + "put=putVal\n" + " # key comment\n" + " key=val"; + Properties p = Properties.loadProperties(new StringReader(given)); + p.put(p.beforeProperty("key"), "put", "putVal"); + expectStoreText(p, expected); + assertThat(p.getComment("key")).containsExactly("# key comment"); + assertThat(p.getComment("put")).isEmpty(); + } + private void expectStoreText(Properties props, String expectedText) throws IOException { StringWriter sw = new StringWriter(); props.store(sw);