Skip to content

Commit 671cbcb

Browse files
committed
feat: Added escaped() and unescaped() functions
Added `escaped()` function that returns a copy of the `Properties` where all non-ISO8859-1 chars in all keys and values are turned into Unicode escape sequences. There's also an `unescaped()` that does the opposite.
1 parent a3c5756 commit 671cbcb

File tree

5 files changed

+280
-20
lines changed

5 files changed

+280
-20
lines changed

src/main/java/org/codejive/properties/Properties.java

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import java.util.regex.Pattern;
1414
import java.util.stream.Collectors;
1515
import java.util.stream.IntStream;
16+
import java.util.stream.Stream;
17+
import java.util.stream.StreamSupport;
1618

1719
/**
1820
* This class is a replacement for <code>java.util.Properties</code>, with the difference that it
@@ -36,6 +38,15 @@ public Properties(Properties defaults) {
3638
tokens = new ArrayList<>();
3739
}
3840

41+
private Properties(Properties defaults, List<PropertiesParser.Token> tokens) {
42+
this.defaults = defaults;
43+
values = new LinkedHashMap<>();
44+
this.tokens = tokens;
45+
rawEntrySet().forEach(e -> {
46+
values.put(unescape(e.getKey()), unescape(e.getValue()));
47+
});
48+
}
49+
3950
/**
4051
* Searches for the property with the specified key in this property list. If the key is not
4152
* 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
186197
}
187198

188199
/**
189-
* Returns the current properties table with all its defaults as a single flattened properties
190-
* table
200+
* Returns the current properties table with all its defaults as a single
201+
* flattened properties table. NB: Result will have no formatting or comments!
191202
*
192203
* @return a <code>Properties</code> object
204+
* @deprecated Use <code>flattened()</code>
193205
*/
206+
@Deprecated
194207
public Properties flatten() {
208+
return flattened();
209+
}
210+
211+
/**
212+
* Returns the current properties table with all its defaults as a single
213+
* flattened properties table. NB: Result will have no formatting or comments!
214+
*
215+
* @return a <code>Properties</code> object
216+
*/
217+
public Properties flattened() {
195218
Properties result = new Properties();
196219
flatten(result);
197220
return result;
@@ -261,12 +284,25 @@ public Set<String> rawKeySet() {
261284
* @return a collection of raw values.
262285
*/
263286
public Collection<String> rawValues() {
264-
return IntStream.range(0, tokens.size())
265-
.filter(idx -> tokens.get(idx).type == PropertiesParser.Type.KEY)
266-
.mapToObj(idx -> tokens.get(idx + 2).getRaw())
287+
return combined(tokens)
288+
.filter(ts -> ts.get(0).type == PropertiesParser.Type.KEY)
289+
.map(ts -> ts.get(2).getRaw())
267290
.collect(Collectors.toList());
268291
}
269292

293+
/**
294+
* Works like <code>entrySet()</code> but returning the raw values. Meaning that the values have
295+
* not been unescaped before being returned.
296+
*
297+
* @return A set of raw key-value entries
298+
*/
299+
public Set<Entry<String, String>> rawEntrySet() {
300+
return combined(tokens)
301+
.filter(ts -> ts.get(0).type == PropertiesParser.Type.KEY)
302+
.map(ts -> new SimpleEntry<>(ts.get(0).getRaw(), ts.get(2).getRaw()))
303+
.collect(Collectors.toCollection(LinkedHashSet::new));
304+
}
305+
270306
@Override
271307
public String get(Object key) {
272308
return values.get(key);
@@ -296,11 +332,11 @@ public String put(String key, String value) {
296332
if (key == null || value == null) {
297333
throw new NullPointerException();
298334
}
299-
String rawValue = escape(value, false);
335+
String rawValue = escapeValue(value);
300336
if (values.containsKey(key)) {
301337
replaceValue(key, rawValue, value);
302338
} else {
303-
String rawKey = escape(key, true);
339+
String rawKey = escapeKey(key);
304340
addNewKeyValue(rawKey, key, rawValue, value);
305341
}
306342
return values.put(key, value);
@@ -575,19 +611,48 @@ private Cursor indexOf(String key) {
575611
return index(
576612
tokens.indexOf(
577613
new PropertiesParser.Token(
578-
PropertiesParser.Type.KEY, escape(key, true), key)));
579-
}
580-
581-
private String escape(String raw, boolean forKey) {
582-
raw = raw.replace("\\", "\\\\");
583-
raw = raw.replace("\n", "\\n");
584-
raw = raw.replace("\r", "\\r");
585-
raw = raw.replace("\t", "\\t");
586-
raw = raw.replace("\f", "\\f");
587-
if (forKey) {
588-
raw = raw.replace(" ", "\\ ");
614+
PropertiesParser.Type.KEY, escapeKey(key), key)));
615+
}
616+
617+
private static String escapeValue(String value) {
618+
return value
619+
.replace("\\", "\\\\")
620+
.replace("\n", "\\n")
621+
.replace("\r", "\\r")
622+
.replace("\t", "\\t")
623+
.replace("\f", "\\f");
624+
}
625+
626+
private static String escapeKey(String key) {
627+
return escapeValue(key).replace(" ", "\\ ");
628+
}
629+
630+
private static String escapeUnicode(String text) {
631+
return replace(
632+
text,
633+
"[^\\x{0000}-\\x{00FF}]",
634+
m -> "\\\\u" + String.format("%04x", (int)m.group(0).charAt(0)));
635+
}
636+
637+
private static String unescapeUnicode(String escape) {
638+
StringBuilder txt = new StringBuilder();
639+
for (int i = 0; i < escape.length(); i++) {
640+
char ch = escape.charAt(i);
641+
if (ch == '\\') {
642+
ch = escape.charAt(++i);
643+
if (ch == 'u') {
644+
String num = escape.substring(i + 1, i + 5);
645+
txt.append((char) Integer.parseInt(num, 16));
646+
i += 4;
647+
} else {
648+
txt.append('\\');
649+
txt.append(ch);
650+
}
651+
} else {
652+
txt.append(ch);
653+
}
589654
}
590-
return raw;
655+
return txt.toString();
591656
}
592657

593658
private static String replace(String input, String regex, Function<Matcher, String> callback) {
@@ -605,6 +670,87 @@ private static String replace(String input, Pattern regex, Function<Matcher, Str
605670
return resultString.toString();
606671
}
607672

673+
/**
674+
* Returns a copy of the object where all characters, in keys and values that are not in
675+
* the Unicode range of 0x0000-0x00FF, have been escaped. This is useful when using
676+
* <code>store()</code> to write to an output that does not support UTF8.
677+
*
678+
* @return A <code>Properties</code> with encoded keys and values
679+
*/
680+
public Properties escaped() {
681+
return new Properties(defaults != null ? defaults.escaped() : null, escapeTokens(tokens));
682+
}
683+
684+
private static List<PropertiesParser.Token> escapeTokens(List<PropertiesParser.Token> tokens) {
685+
return mapKeyValues(tokens, ts -> Arrays.asList(escapeToken(ts.get(0)), ts.get(1), escapeToken(ts.get(2))));
686+
}
687+
688+
private static PropertiesParser.Token escapeToken(PropertiesParser.Token token) {
689+
String raw = escapeUnicode(token.raw);
690+
if (!raw.equals(token.raw)) {
691+
token = new PropertiesParser.Token(token.type, raw, token.text);
692+
}
693+
return token;
694+
}
695+
696+
/**
697+
* Returns a copy of the object where all Unicode escape sequences, in keys and values,
698+
* have been decoded into their actual Unicode characters. This is useful when using
699+
* <code>store()</code> to write to an output that supports UTF8.
700+
*
701+
* @return A <code>Properties</code> without Unicode escape sequences in its keys and values
702+
*/
703+
public Properties unescaped() {
704+
return new Properties(defaults != null ? defaults.unescaped() : null, unescapeTokens(tokens));
705+
}
706+
707+
private static List<PropertiesParser.Token> unescapeTokens(List<PropertiesParser.Token> tokens) {
708+
return mapKeyValues(tokens, ts -> Arrays.asList(unescapeToken(ts.get(0)), ts.get(1), unescapeToken(ts.get(2))));
709+
}
710+
711+
private static PropertiesParser.Token unescapeToken(PropertiesParser.Token token) {
712+
String raw = unescapeUnicode(token.raw);
713+
if (!raw.equals(token.raw)) {
714+
token = new PropertiesParser.Token(token.type, raw, token.text);
715+
}
716+
return token;
717+
}
718+
719+
private static List<PropertiesParser.Token> mapKeyValues(
720+
List<PropertiesParser.Token> tokens,
721+
Function<List<PropertiesParser.Token>, List<PropertiesParser.Token>> mapper) {
722+
return combined(tokens).map(ts -> {
723+
if (ts.get(0).type == PropertiesParser.Type.KEY) {
724+
return mapper.apply(ts);
725+
} else {
726+
return ts;
727+
}
728+
}).flatMap(Collection::stream).collect(Collectors.toList());
729+
}
730+
731+
private static Stream<List<PropertiesParser.Token>> combined(List<PropertiesParser.Token> tokens) {
732+
Iterator<List<PropertiesParser.Token>> iter = new Iterator<List<PropertiesParser.Token>>() {
733+
Iterator<PropertiesParser.Token> i = tokens.iterator();
734+
735+
@Override
736+
public boolean hasNext() {
737+
return i.hasNext();
738+
}
739+
740+
@Override
741+
public List<PropertiesParser.Token> next() {
742+
PropertiesParser.Token t = i.next();
743+
if (t.type == PropertiesParser.Type.KEY) {
744+
return Arrays.asList(t, i.next(), i.next());
745+
} else {
746+
return Collections.singletonList(t);
747+
}
748+
}
749+
};
750+
751+
return StreamSupport.stream(Spliterators.spliterator(iter, tokens.size(), Spliterator.SORTED), false);
752+
}
753+
608754
/**
609755
* Returns a <code>java.util.Properties</code> with the same contents as this object. The
610756
* information is a copy, changes to one Properties object will not affect the other.

src/main/java/org/codejive/properties/PropertiesParser.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ class PropertiesParser {
2121
public enum Type {
2222
/** The key part of a key-value pair */
2323
KEY,
24-
/** The separator between a key and a value */
24+
/** The separator between a key and a value. This will include any whitespace that exists
25+
* before and after the separator!
26+
*/
2527
SEPARATOR,
2628
/** The value part of a key-value pair */
2729
VALUE,
@@ -293,6 +295,13 @@ private String string() {
293295
return result;
294296
}
295297

298+
/**
299+
* Returns a copy of the given string where all escape sequences
300+
* have been turned into their representative values.
301+
*
302+
* @param escape Input string
303+
* @return Decoded string
304+
*/
296305
static String unescape(String escape) {
297306
StringBuilder txt = new StringBuilder();
298307
for (int i = 0; i < escape.length(); i++) {

src/test/java/org/codejive/properties/TestProperties.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.nio.file.Files;
88
import java.nio.file.Path;
99
import java.nio.file.Paths;
10+
import java.util.AbstractMap;
1011
import java.util.Collections;
1112
import java.util.Iterator;
1213
import org.junit.jupiter.api.Test;
@@ -40,6 +41,24 @@ void testLoad() throws IOException, URISyntaxException {
4041
"value",
4142
"one \\\n two \\\n\tthree",
4243
"\\u1234\u1234");
44+
assertThat(p.entrySet())
45+
.containsExactly(
46+
new AbstractMap.SimpleEntry<>("one", "simple"),
47+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
48+
new AbstractMap.SimpleEntry<>("three", "and escapes\n\t\r\f"),
49+
new AbstractMap.SimpleEntry<>(" with spaces", "everywhere "),
50+
new AbstractMap.SimpleEntry<>("altsep", "value"),
51+
new AbstractMap.SimpleEntry<>("multiline", "one two three"),
52+
new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234"));
53+
assertThat(p.rawEntrySet())
54+
.containsExactly(
55+
new AbstractMap.SimpleEntry<>("one", "simple"),
56+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
57+
new AbstractMap.SimpleEntry<>("three", "and escapes\\n\\t\\r\\f"),
58+
new AbstractMap.SimpleEntry<>("\\ with\\ spaces", "everywhere "),
59+
new AbstractMap.SimpleEntry<>("altsep", "value"),
60+
new AbstractMap.SimpleEntry<>("multiline", "one \\\n two \\\n\tthree"),
61+
new AbstractMap.SimpleEntry<>("key.4", "\\u1234\u1234"));
4362
}
4463

4564
@Test
@@ -180,6 +199,24 @@ void testPut() throws IOException, URISyntaxException {
180199
"value",
181200
"one two three",
182201
"\u1234\u1234");
202+
assertThat(p.entrySet())
203+
.containsExactly(
204+
new AbstractMap.SimpleEntry<>("one", "simple"),
205+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
206+
new AbstractMap.SimpleEntry<>("three", "and escapes\n\t\r\f"),
207+
new AbstractMap.SimpleEntry<>(" with spaces", "everywhere "),
208+
new AbstractMap.SimpleEntry<>("altsep", "value"),
209+
new AbstractMap.SimpleEntry<>("multiline", "one two three"),
210+
new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234"));
211+
assertThat(p.rawEntrySet())
212+
.containsExactly(
213+
new AbstractMap.SimpleEntry<>("one", "simple"),
214+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
215+
new AbstractMap.SimpleEntry<>("three", "and escapes\\n\\t\\r\\f"),
216+
new AbstractMap.SimpleEntry<>("\\ with\\ spaces", "everywhere "),
217+
new AbstractMap.SimpleEntry<>("altsep", "value"),
218+
new AbstractMap.SimpleEntry<>("multiline", "one two three"),
219+
new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234"));
183220
StringWriter sw = new StringWriter();
184221
p.store(sw);
185222
assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-put.properties")));
@@ -236,6 +273,24 @@ void testPutRaw() throws IOException, URISyntaxException {
236273
"value",
237274
"one \\\n two \\\n\tthree",
238275
"\\u1234\u1234");
276+
assertThat(p.entrySet())
277+
.containsExactly(
278+
new AbstractMap.SimpleEntry<>("one", "simple"),
279+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
280+
new AbstractMap.SimpleEntry<>("three", "and escapes\n\t\r\f"),
281+
new AbstractMap.SimpleEntry<>(" with spaces", "everywhere "),
282+
new AbstractMap.SimpleEntry<>("altsep", "value"),
283+
new AbstractMap.SimpleEntry<>("multiline", "one two three"),
284+
new AbstractMap.SimpleEntry<>("key.4", "\u1234\u1234"));
285+
assertThat(p.rawEntrySet())
286+
.containsExactly(
287+
new AbstractMap.SimpleEntry<>("one", "simple"),
288+
new AbstractMap.SimpleEntry<>("two", "value containing spaces"),
289+
new AbstractMap.SimpleEntry<>("three", "and escapes\\n\\t\\r\\f"),
290+
new AbstractMap.SimpleEntry<>("\\ with\\ spaces", "everywhere "),
291+
new AbstractMap.SimpleEntry<>("altsep", "value"),
292+
new AbstractMap.SimpleEntry<>("multiline", "one \\\n two \\\n\tthree"),
293+
new AbstractMap.SimpleEntry<>("key.4", "\\u1234\u1234"));
239294
StringWriter sw = new StringWriter();
240295
p.store(sw);
241296
assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-putraw.properties")));
@@ -485,6 +540,22 @@ void testInteropPutLoad() throws IOException, URISyntaxException {
485540
"\u1234");
486541
}
487542

543+
@Test
544+
void testEscaped() throws IOException, URISyntaxException {
545+
Properties p = Properties.loadProperties(getResource("/test.properties"));
546+
StringWriter sw = new StringWriter();
547+
p.escaped().store(sw);
548+
assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-escaped.properties")));
549+
}
550+
551+
@Test
552+
void testUnescaped() throws IOException, URISyntaxException {
553+
Properties p = Properties.loadProperties(getResource("/test.properties"));
554+
StringWriter sw = new StringWriter();
555+
p.unescaped().store(sw);
556+
assertThat(sw.toString()).isEqualTo(readAll(getResource("/test-unescaped.properties")));
557+
}
558+
488559
private Path getResource(String name) throws URISyntaxException {
489560
return Paths.get(getClass().getResource(name).toURI());
490561
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#comment1
2+
# comment2
3+
4+
! comment3
5+
one=simple
6+
two=value containing spaces
7+
# another comment
8+
! and a comment
9+
! block
10+
three=and escapes\n\t\r\f
11+
\ with\ spaces = everywhere
12+
altsep:value
13+
multiline = one \
14+
two \
15+
three
16+
key.4 = \u1234\u1234
17+
# final comment

0 commit comments

Comments
 (0)