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 79498f7..36505f1 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")); } @@ -467,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(); @@ -553,7 +604,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 +673,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"); @@ -713,11 +764,90 @@ 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()); } private String readAll(Path f) throws IOException { - return new String(Files.readAllBytes(f)); + return new String(Files.readAllBytes(f), StandardCharsets.UTF_8); } }