diff --git a/src/main/java/org/codejive/properties/Properties.java b/src/main/java/org/codejive/properties/Properties.java index 1d264bf..d6087c2 100644 --- a/src/main/java/org/codejive/properties/Properties.java +++ b/src/main/java/org/codejive/properties/Properties.java @@ -13,6 +13,8 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; /** * This class is a replacement for java.util.Properties, with the difference that it @@ -36,6 +38,15 @@ public Properties(Properties defaults) { tokens = new ArrayList<>(); } + private Properties(Properties defaults, List tokens) { + this.defaults = defaults; + values = new LinkedHashMap<>(); + this.tokens = tokens; + rawEntrySet().forEach(e -> { + values.put(unescape(e.getKey()), unescape(e.getValue())); + }); + } + /** * Searches for the property with the specified key in this property list. If the key is not * found in this property list, the default property list, and its defaults, recursively, are @@ -186,12 +197,24 @@ public void storeToXML(OutputStream os, String comment, String encoding) throws } /** - * Returns the current properties table with all its defaults as a single flattened properties - * table + * Returns the current properties table with all its defaults as a single + * flattened properties table. NB: Result will have no formatting or comments! * * @return a Properties object + * @deprecated Use flattened() */ + @Deprecated public Properties flatten() { + return flattened(); + } + + /** + * Returns the current properties table with all its defaults as a single + * flattened properties table. NB: Result will have no formatting or comments! + * + * @return a Properties object + */ + public Properties flattened() { Properties result = new Properties(); flatten(result); return result; @@ -261,12 +284,25 @@ public Set rawKeySet() { * @return a collection of raw values. */ public Collection rawValues() { - return IntStream.range(0, tokens.size()) - .filter(idx -> tokens.get(idx).type == PropertiesParser.Type.KEY) - .mapToObj(idx -> tokens.get(idx + 2).getRaw()) + return combined(tokens) + .filter(ts -> ts.get(0).type == PropertiesParser.Type.KEY) + .map(ts -> ts.get(2).getRaw()) .collect(Collectors.toList()); } + /** + * Works like entrySet() but returning the raw values. Meaning that the values have + * not been unescaped before being returned. + * + * @return A set of raw key-value entries + */ + public Set> rawEntrySet() { + return combined(tokens) + .filter(ts -> ts.get(0).type == PropertiesParser.Type.KEY) + .map(ts -> new SimpleEntry<>(ts.get(0).getRaw(), ts.get(2).getRaw())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + @Override public String get(Object key) { return values.get(key); @@ -296,11 +332,11 @@ public String put(String key, String value) { if (key == null || value == null) { throw new NullPointerException(); } - String rawValue = escape(value, false); + String rawValue = escapeValue(value); if (values.containsKey(key)) { replaceValue(key, rawValue, value); } else { - String rawKey = escape(key, true); + String rawKey = escapeKey(key); addNewKeyValue(rawKey, key, rawValue, value); } return values.put(key, value); @@ -575,23 +611,48 @@ private Cursor indexOf(String key) { return index( tokens.indexOf( new PropertiesParser.Token( - PropertiesParser.Type.KEY, escape(key, true), key))); - } - - private String escape(String raw, boolean forKey) { - raw = raw.replace("\n", "\\n"); - raw = raw.replace("\r", "\\r"); - raw = raw.replace("\t", "\\t"); - raw = raw.replace("\f", "\\f"); - if (forKey) { - raw = raw.replace(" ", "\\ "); + PropertiesParser.Type.KEY, escapeKey(key), key))); + } + + private static String escapeValue(String value) { + return value + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\f", "\\f"); + } + + private static String escapeKey(String key) { + return escapeValue(key).replace(" ", "\\ "); + } + + private static String escapeUnicode(String text) { + return replace( + text, + "[^\\x{0000}-\\x{00FF}]", + m -> "\\\\u" + String.format("%04x", (int)m.group(0).charAt(0))); + } + + private static String unescapeUnicode(String escape) { + StringBuilder txt = new StringBuilder(); + for (int i = 0; i < escape.length(); i++) { + char ch = escape.charAt(i); + if (ch == '\\') { + ch = escape.charAt(++i); + if (ch == 'u') { + String num = escape.substring(i + 1, i + 5); + txt.append((char) Integer.parseInt(num, 16)); + i += 4; + } else { + txt.append('\\'); + txt.append(ch); + } + } else { + txt.append(ch); + } } - raw = - replace( - raw, - "[^\\x{0000}-\\x{00FF}]", - m -> "\\\\u" + String.format("%04x", (int)m.group(0).charAt(0))); - return raw; + return txt.toString(); } private static String replace(String input, String regex, Function callback) { @@ -609,6 +670,87 @@ private static String replace(String input, Pattern regex, Functionstore() to write to an output that does not support UTF8. + * + * @return A Properties with encoded keys and values + */ + public Properties escaped() { + return new Properties(defaults != null ? defaults.escaped() : null, escapeTokens(tokens)); + } + + private static List escapeTokens(List tokens) { + return mapKeyValues(tokens, ts -> Arrays.asList(escapeToken(ts.get(0)), ts.get(1), escapeToken(ts.get(2)))); + } + + private static PropertiesParser.Token escapeToken(PropertiesParser.Token token) { + String raw = escapeUnicode(token.raw); + if (!raw.equals(token.raw)) { + token = new PropertiesParser.Token(token.type, raw, token.text); + } + return token; + } + + /** + * Returns a copy of the object where all Unicode escape sequences, in keys and values, + * have been decoded into their actual Unicode characters. This is useful when using + * store() to write to an output that supports UTF8. + * + * @return A Properties without Unicode escape sequences in its keys and values + */ + public Properties unescaped() { + return new Properties(defaults != null ? defaults.unescaped() : null, unescapeTokens(tokens)); + } + + private static List unescapeTokens(List tokens) { + return mapKeyValues(tokens, ts -> Arrays.asList(unescapeToken(ts.get(0)), ts.get(1), unescapeToken(ts.get(2)))); + } + + private static PropertiesParser.Token unescapeToken(PropertiesParser.Token token) { + String raw = unescapeUnicode(token.raw); + if (!raw.equals(token.raw)) { + token = new PropertiesParser.Token(token.type, raw, token.text); + } + return token; + } + + private static List mapKeyValues( + List tokens, + Function, List> mapper) { + return combined(tokens).map(ts -> { + if (ts.get(0).type == PropertiesParser.Type.KEY) { + return mapper.apply(ts); + } else { + return ts; + } + }).flatMap(Collection::stream).collect(Collectors.toList()); + } + + private static Stream> combined(List tokens) { + Iterator> iter = new Iterator>() { + Iterator i = tokens.iterator(); + + @Override + public boolean hasNext() { + return i.hasNext(); + } + + @Override + public List next() { + PropertiesParser.Token t = i.next(); + if (t.type == PropertiesParser.Type.KEY) { + return Arrays.asList(t, i.next(), i.next()); + } else { + return Collections.singletonList(t); + } + } + }; + + return StreamSupport.stream(Spliterators.spliterator(iter, tokens.size(), Spliterator.SORTED), false); + } + /** * Returns a java.util.Properties with the same contents as this object. The * information is a copy, changes to one Properties object will not affect the other. diff --git a/src/main/java/org/codejive/properties/PropertiesParser.java b/src/main/java/org/codejive/properties/PropertiesParser.java index 4f197e7..2388e44 100644 --- a/src/main/java/org/codejive/properties/PropertiesParser.java +++ b/src/main/java/org/codejive/properties/PropertiesParser.java @@ -21,7 +21,9 @@ class PropertiesParser { public enum Type { /** The key part of a key-value pair */ KEY, - /** The separator between a key and a value */ + /** The separator between a key and a value. This will include any whitespace that exists + * before and after the separator! + */ SEPARATOR, /** The value part of a key-value pair */ VALUE, @@ -293,6 +295,13 @@ private String string() { return result; } + /** + * Returns a copy of the given string where all escape sequences + * have been turned into their representative values. + * + * @param escape Input string + * @return Decoded string + */ static String unescape(String escape) { StringBuilder txt = new StringBuilder(); for (int i = 0; i < escape.length(); i++) { diff --git a/src/test/java/org/codejive/properties/TestProperties.java b/src/test/java/org/codejive/properties/TestProperties.java index dd84047..badd1de 100644 --- a/src/test/java/org/codejive/properties/TestProperties.java +++ b/src/test/java/org/codejive/properties/TestProperties.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.AbstractMap; import java.util.Collections; import java.util.Iterator; import org.junit.jupiter.api.Test; @@ -30,7 +31,7 @@ void testLoad() throws IOException, URISyntaxException { "everywhere ", "value", "one two three", - "\u1234"); + "\u1234\u1234"); assertThat(p.rawValues()) .containsExactly( "simple", @@ -39,7 +40,25 @@ void testLoad() throws IOException, URISyntaxException { "everywhere ", "value", "one \\\n two \\\n\tthree", - "\\u1234"); + "\\u1234\u1234"); + assertThat(p.entrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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 two three"), + new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234")); + assertThat(p.rawEntrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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<>("key.4", "\\u1234\u1234")); } @Test @@ -69,7 +88,7 @@ void testGet() throws IOException, URISyntaxException { assertThat(p.get(" with spaces")).isEqualTo("everywhere "); assertThat(p.get("altsep")).isEqualTo("value"); assertThat(p.get("multiline")).isEqualTo("one two three"); - assertThat(p.get("key.4")).isEqualTo("\u1234"); + assertThat(p.get("key.4")).isEqualTo("\u1234\u1234"); } @Test @@ -102,7 +121,7 @@ void testGetProperty() throws IOException, URISyntaxException { assertThat(p.getProperty(" with spaces")).isEqualTo("everywhere "); assertThat(p.getProperty("altsep")).isEqualTo(""); assertThat(p.getProperty("multiline")).isEqualTo("one two three"); - assertThat(p.getProperty("key.4")).isEqualTo("\u1234"); + assertThat(p.getProperty("key.4")).isEqualTo("\u1234\u1234"); assertThat(p.getProperty("five")).isEqualTo("5"); assertThat(p.getPropertyComment("five")).containsExactly("# a new comment"); StringWriter sw = new StringWriter(); @@ -119,7 +138,7 @@ void testGetRaw() throws IOException, URISyntaxException { assertThat(p.getRaw(" with spaces")).isEqualTo("everywhere "); assertThat(p.getRaw("altsep")).isEqualTo("value"); assertThat(p.getRaw("multiline")).isEqualTo("one \\\n two \\\n\tthree"); - assertThat(p.getRaw("key.4")).isEqualTo("\\u1234"); + assertThat(p.getRaw("key.4")).isEqualTo("\\u1234\u1234"); } @Test @@ -154,7 +173,7 @@ void testPut() throws IOException, URISyntaxException { p.put(" with spaces", "everywhere "); p.put("altsep", "value"); p.put("multiline", "one two three"); - p.put("key.4", "\u1234"); + p.put("key.4", "\u1234\u1234"); assertThat(p).size().isEqualTo(7); assertThat(p.keySet()) .containsExactly( @@ -170,7 +189,7 @@ void testPut() throws IOException, URISyntaxException { "everywhere ", "value", "one two three", - "\u1234"); + "\u1234\u1234"); assertThat(p.rawValues()) .containsExactly( "simple", @@ -179,7 +198,25 @@ void testPut() throws IOException, URISyntaxException { "everywhere ", "value", "one two three", - "\\u1234"); + "\u1234\u1234"); + assertThat(p.entrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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 two three"), + new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234")); + assertThat(p.rawEntrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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 two three"), + new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234")); StringWriter sw = new StringWriter(); p.store(sw); assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-put.properties"))); @@ -195,7 +232,7 @@ void testSetProperty() throws IOException, URISyntaxException { p.setProperty(" with spaces", "everywhere "); p.setProperty("altsep", "value"); p.setProperty("multiline", "one two three"); - p.setProperty("key.4", "\u1234"); + p.setProperty("key.4", "\u1234\u1234"); StringWriter sw = new StringWriter(); p.store(sw); assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-setproperty.properties"))); @@ -210,7 +247,7 @@ void testPutRaw() throws IOException, URISyntaxException { p.putRaw("\\ with\\ spaces", "everywhere "); p.putRaw("altsep", "value"); p.putRaw("multiline", "one \\\n two \\\n\tthree"); - p.putRaw("key.4", "\\u1234"); + p.putRaw("key.4", "\\u1234\u1234"); assertThat(p).size().isEqualTo(7); assertThat(p.keySet()) .containsExactly( @@ -226,7 +263,7 @@ void testPutRaw() throws IOException, URISyntaxException { "everywhere ", "value", "one two three", - "\u1234"); + "\u1234\u1234"); assertThat(p.rawValues()) .containsExactly( "simple", @@ -235,7 +272,25 @@ void testPutRaw() throws IOException, URISyntaxException { "everywhere ", "value", "one \\\n two \\\n\tthree", - "\\u1234"); + "\\u1234\u1234"); + assertThat(p.entrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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 two three"), + new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234")); + assertThat(p.rawEntrySet()) + .containsExactly( + new AbstractMap.SimpleEntry<>("one", "simple"), + new AbstractMap.SimpleEntry<>("two", "value containing spaces"), + 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<>("key.4", "\\u1234\u1234")); StringWriter sw = new StringWriter(); p.store(sw); assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-putraw.properties"))); @@ -330,7 +385,8 @@ void testPutNull() throws IOException, URISyntaxException { @Test void testPutUnicode() throws IOException, URISyntaxException { Properties p = new Properties(); - p.put("test", "الألبانية"); + p.putRaw("encoded", "\\u0627\\u0644\\u0623\\u0644\\u0628\\u0627\\u0646\\u064a\\u0629"); + p.put("text", "\u0627\u0644\u0623\u0644\u0628\u0627\u0646\u064a\u0629"); StringWriter sw = new StringWriter(); p.store(sw); assertThat(sw.toString()) @@ -431,7 +487,7 @@ public void testInteropLoad() throws IOException, URISyntaxException { "everywhere ", "value", "one two three", - "\u1234"); + "\u1234\u1234"); } @Test @@ -445,7 +501,7 @@ void testInteropStore() throws IOException, URISyntaxException { assertThat(sw.toString()).contains("\\ with\\ spaces=everywhere \n"); assertThat(sw.toString()).contains("altsep=value\n"); assertThat(sw.toString()).contains("multiline=one two three\n"); - assertThat(sw.toString()).contains("key.4=\u1234\n"); + assertThat(sw.toString()).contains("key.4=\u1234\u1234\n"); } @Test @@ -484,6 +540,22 @@ void testInteropPutLoad() throws IOException, URISyntaxException { "\u1234"); } + @Test + void testEscaped() throws IOException, URISyntaxException { + Properties p = Properties.loadProperties(getResource("/test.properties")); + StringWriter sw = new StringWriter(); + p.escaped().store(sw); + assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-escaped.properties"))); + } + + @Test + void testUnescaped() throws IOException, URISyntaxException { + Properties p = Properties.loadProperties(getResource("/test.properties")); + StringWriter sw = new StringWriter(); + p.unescaped().store(sw); + assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-unescaped.properties"))); + } + private Path getResource(String name) throws URISyntaxException { return Paths.get(getClass().getResource(name).toURI()); } diff --git a/src/test/resources/test-comment.properties b/src/test/resources/test-comment.properties index b02026c..0e0d3d1 100644 --- a/src/test/resources/test-comment.properties +++ b/src/test/resources/test-comment.properties @@ -13,5 +13,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment diff --git a/src/test/resources/test-escaped.properties b/src/test/resources/test-escaped.properties new file mode 100644 index 0000000..7c6e49c --- /dev/null +++ b/src/test/resources/test-escaped.properties @@ -0,0 +1,17 @@ +#comment1 +# comment2 + +! comment3 +one=simple +two=value containing spaces +# another comment +! and a comment +! block +three=and escapes\n\t\r\f +\ with\ spaces = everywhere +altsep:value +multiline = one \ + two \ + three +key.4 = \u1234\u1234 +# final comment diff --git a/src/test/resources/test-getproperty.properties b/src/test/resources/test-getproperty.properties index fb1e57b..146224a 100644 --- a/src/test/resources/test-getproperty.properties +++ b/src/test/resources/test-getproperty.properties @@ -4,5 +4,5 @@ three=and escapes\n\t\r\f \ with\ spaces=everywhere altsep= multiline=one two three -key.4=\u1234 +key.4=ሴሴ five=5 \ No newline at end of file diff --git a/src/test/resources/test-put.properties b/src/test/resources/test-put.properties index 5c61be4..17e673a 100644 --- a/src/test/resources/test-put.properties +++ b/src/test/resources/test-put.properties @@ -4,4 +4,4 @@ three=and escapes\n\t\r\f \ with\ spaces=everywhere altsep=value multiline=one two three -key.4=\u1234 \ No newline at end of file +key.4=ሴሴ \ No newline at end of file diff --git a/src/test/resources/test-putnew.properties b/src/test/resources/test-putnew.properties index 69ecb32..5e960de 100644 --- a/src/test/resources/test-putnew.properties +++ b/src/test/resources/test-putnew.properties @@ -13,6 +13,6 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ five=5 # final comment diff --git a/src/test/resources/test-putraw.properties b/src/test/resources/test-putraw.properties index ff54e36..61b173f 100644 --- a/src/test/resources/test-putraw.properties +++ b/src/test/resources/test-putraw.properties @@ -6,4 +6,4 @@ altsep=value multiline=one \ two \ three -key.4=\u1234 \ No newline at end of file +key.4=\u1234ሴ \ No newline at end of file diff --git a/src/test/resources/test-putunicode.properties b/src/test/resources/test-putunicode.properties index 3978ea1..29bca09 100644 --- a/src/test/resources/test-putunicode.properties +++ b/src/test/resources/test-putunicode.properties @@ -1 +1,2 @@ -test=\u0627\u0644\u0623\u0644\u0628\u0627\u0646\u064a\u0629 \ No newline at end of file +encoded=\u0627\u0644\u0623\u0644\u0628\u0627\u0646\u064a\u0629 +text=الألبانية \ No newline at end of file diff --git a/src/test/resources/test-removecomment.properties b/src/test/resources/test-removecomment.properties index f891dec..f3fb147 100644 --- a/src/test/resources/test-removecomment.properties +++ b/src/test/resources/test-removecomment.properties @@ -12,5 +12,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment diff --git a/src/test/resources/test-removefirst.properties b/src/test/resources/test-removefirst.properties index 88d9407..369c459 100644 --- a/src/test/resources/test-removefirst.properties +++ b/src/test/resources/test-removefirst.properties @@ -11,5 +11,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment diff --git a/src/test/resources/test-removemiddle.properties b/src/test/resources/test-removemiddle.properties index aaa6461..6d9e10b 100644 --- a/src/test/resources/test-removemiddle.properties +++ b/src/test/resources/test-removemiddle.properties @@ -9,5 +9,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment diff --git a/src/test/resources/test-setproperty.properties b/src/test/resources/test-setproperty.properties index d1e31fc..e4433fa 100644 --- a/src/test/resources/test-setproperty.properties +++ b/src/test/resources/test-setproperty.properties @@ -8,4 +8,4 @@ three=and escapes\n\t\r\f \ with\ spaces=everywhere altsep=value multiline=one two three -key.4=\u1234 \ No newline at end of file +key.4=ሴሴ \ No newline at end of file diff --git a/src/test/resources/test-storeheader.properties b/src/test/resources/test-storeheader.properties index 7ca6eec..3ab1ef7 100644 --- a/src/test/resources/test-storeheader.properties +++ b/src/test/resources/test-storeheader.properties @@ -12,5 +12,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment diff --git a/src/test/resources/test-unescaped.properties b/src/test/resources/test-unescaped.properties new file mode 100644 index 0000000..bac7a7f --- /dev/null +++ b/src/test/resources/test-unescaped.properties @@ -0,0 +1,17 @@ +#comment1 +# comment2 + +! comment3 +one=simple +two=value containing spaces +# another comment +! and a comment +! block +three=and escapes\n\t\r\f +\ with\ spaces = everywhere +altsep:value +multiline = one \ + two \ + three +key.4 = ሴሴ +# final comment diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties index ed2a301..f2d73bd 100644 --- a/src/test/resources/test.properties +++ b/src/test/resources/test.properties @@ -13,5 +13,5 @@ altsep:value multiline = one \ two \ three -key.4 = \u1234 +key.4 = \u1234ሴ # final comment