Skip to content

Commit 36fb7a7

Browse files
committed
feat: Can now set values and get/set comments
We now also support setting and getting raw keys and values.
1 parent 98c0c00 commit 36fb7a7

File tree

7 files changed

+441
-43
lines changed

7 files changed

+441
-43
lines changed

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

Lines changed: 258 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@
1010
import java.util.AbstractMap;
1111
import java.util.AbstractSet;
1212
import java.util.ArrayList;
13+
import java.util.Arrays;
14+
import java.util.Collection;
15+
import java.util.Collections;
1316
import java.util.Iterator;
1417
import java.util.LinkedHashMap;
18+
import java.util.LinkedHashSet;
1519
import java.util.List;
20+
import java.util.NoSuchElementException;
1621
import java.util.Set;
22+
import java.util.function.Function;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
1725
import java.util.stream.Collectors;
26+
import java.util.stream.IntStream;
27+
28+
import static org.codejive.properties.PropertiesParser.unescape;
1829

1930
public class Properties extends AbstractMap<String, String> {
2031
private final LinkedHashMap<String, String> values = new LinkedHashMap<>();
@@ -26,7 +37,7 @@ public Set<Entry<String, String>> entrySet() {
2637
@Override
2738
public Iterator<Entry<String, String>> iterator() {
2839
return new Iterator<Entry<String, String>>() {
29-
Iterator<Entry<String, String>> iter = values.entrySet().iterator();
40+
final Iterator<Entry<String, String>> iter = values.entrySet().iterator();
3041

3142
@Override
3243
public boolean hasNext() {
@@ -53,18 +64,263 @@ public int size() {
5364
};
5465
}
5566

67+
public Set<String> rawKeySet() {
68+
return tokens.stream()
69+
.filter(t -> t.type == PropertiesParser.Type.KEY)
70+
.map(PropertiesParser.Token::getRaw)
71+
.collect(Collectors.toCollection(LinkedHashSet::new));
72+
}
73+
74+
public Collection<String> rawValues() {
75+
return IntStream.range(0, tokens.size())
76+
.filter(idx -> tokens.get(idx).type == PropertiesParser.Type.KEY)
77+
.mapToObj(idx -> tokens.get(idx + 2).getRaw())
78+
.collect(Collectors.toList());
79+
}
80+
81+
@Override
82+
public String get(Object key) {
83+
return values.get(key);
84+
}
85+
86+
public String getRaw(String rawKey) {
87+
int idx = indexOf(unescape(rawKey));
88+
if (idx >=0) {
89+
return tokens.get(idx + 2).getRaw();
90+
} else {
91+
return null;
92+
}
93+
}
94+
5695
@Override
5796
public String put(String key, String value) {
58-
// TODO handle adds and replaces
97+
String rawKey = escape(key, true);
98+
String rawValue = escape(value, false);
99+
if (values.containsKey(key)) {
100+
int idx = indexOf(key);
101+
addNew(idx, rawKey, key, rawValue, value);
102+
} else {
103+
addNew(-1, rawKey, key, rawValue, value);
104+
}
105+
return values.put(key, value);
106+
}
107+
108+
/**
109+
* Works like `put()` but uses raw values for keys and values.
110+
* This means these keys and values will not be escaped before being serialized.
111+
* @param rawKey key with which the specified value is to be associated
112+
* @param rawValue value to be associated with the specified key
113+
* @return the previous value associated with key, or null if there was no mapping for key.
114+
*/
115+
public String putRaw(String rawKey, String rawValue) {
116+
String key = unescape(rawKey);
117+
String value = unescape(rawValue);
118+
if (values.containsKey(key)) {
119+
int idx = indexOf(key);
120+
addNew(idx, rawKey, key, rawValue, value);
121+
} else {
122+
addNew(-1, rawKey, key, rawValue, value);
123+
}
59124
return values.put(key, value);
60125
}
61126

127+
// Add new tokens to the end of the list of tokens
128+
private void addNew(int index, String rawKey, String key, String rawValue, String value) {
129+
// Add a newline whitespace token if necessary
130+
int idx = index >= 0 ? index : tokens.size();
131+
if (idx > 0) {
132+
PropertiesParser.Token token = tokens.get(idx - 1);
133+
if (token.getType() != PropertiesParser.Type.WHITESPACE) {
134+
addToken(index, new PropertiesParser.Token(PropertiesParser.Type.WHITESPACE, "\n"));
135+
}
136+
}
137+
// Add tokens for key, separator and value
138+
addToken(index, new PropertiesParser.Token(PropertiesParser.Type.KEY, rawKey, key));
139+
addToken(index, new PropertiesParser.Token(PropertiesParser.Type.SEPARATOR, "="));
140+
addToken(index, new PropertiesParser.Token(PropertiesParser.Type.VALUE, rawValue, value));
141+
}
142+
143+
private void addToken(int index, PropertiesParser.Token token) {
144+
if (index >= 0) {
145+
tokens.add(index, token);
146+
} else {
147+
tokens.add(token);
148+
}
149+
}
150+
62151
@Override
63152
public String remove(Object key) {
64153
// TODO handle remove
65154
return values.remove(key);
66155
}
67156

157+
/**
158+
* Gather all the comments directly before the given key
159+
* and return them as a list. The list will only contain
160+
* those lines that immediately follow one another, once
161+
* a non-comment line is encountered gathering will stop.
162+
* @param key The key to look for
163+
* @return A list of comment strings or an empty list if
164+
* no comments lines were found or the key doesn't exist.
165+
*/
166+
public List<String> getComment(String key) {
167+
return getComment(findCommentLines(key));
168+
}
169+
170+
private List<String> getComment(List<Integer> indices) {
171+
return Collections.unmodifiableList(indices.stream().map(idx -> tokens.get(idx).getText()).collect(Collectors.toList()));
172+
}
173+
174+
public List<String> setComment(String key, String... comments) {
175+
return setComment(key, Arrays.asList(comments));
176+
}
177+
178+
public List<String> setComment(String key, List<String> comments) {
179+
int idx = indexOf(key);
180+
if (idx < 0) {
181+
throw new NoSuchElementException("Key not found: " + key);
182+
}
183+
List<Integer> indices = findCommentLines(idx);
184+
List<String> oldcs = getComment(indices);
185+
String prefix = oldcs.isEmpty() ? "# " : getPrefix(oldcs.get(0));
186+
List<String> newcs = normalizeComments(comments, prefix);
187+
188+
// Replace existing comments with new ones
189+
// (doing it like this respects existing whitespace)
190+
int i;
191+
for (i = 0; i < indices.size() && i < newcs.size(); i++) {
192+
int n = indices.get(i);
193+
tokens.set(n, new PropertiesParser.Token(PropertiesParser.Type.COMMENT, newcs.get(i)));
194+
}
195+
196+
// Remove any excess lines (when there are fewer new lines than old ones)
197+
if (i < indices.size()) {
198+
int del = indices.get(i);
199+
int delcnt = idx - del;
200+
for (int j = 0; j < delcnt; j++) {
201+
tokens.remove(del);
202+
}
203+
}
204+
205+
// Add any additional lines (when there are more new lines than old ones)
206+
int ins = idx;
207+
for (int j = i; j < newcs.size(); j++) {
208+
tokens.add(ins++, new PropertiesParser.Token(PropertiesParser.Type.COMMENT, newcs.get(j)));
209+
tokens.add(ins++, new PropertiesParser.Token(PropertiesParser.Type.WHITESPACE, "\n"));
210+
}
211+
212+
return oldcs;
213+
}
214+
215+
/**
216+
* Takes a list of comments and makes sure each of them starts with
217+
* a valid comment character (either '#' or '!'). If only some lines
218+
* have missing comment prefixes it will use the ones that were used
219+
* on previous lines, if not the default will be the value passed as
220+
* `preferredPrefix`.
221+
* @param comments list of comment lines
222+
* @param preferredPrefix the preferred prefix to use
223+
* @return list of comment lines
224+
*/
225+
private List<String> normalizeComments(List<String> comments, String preferredPrefix) {
226+
ArrayList<String> res = new ArrayList<>(comments.size());
227+
for (String c : comments) {
228+
if (getPrefix(c).isEmpty()) {
229+
c = preferredPrefix + c;
230+
} else {
231+
preferredPrefix = getPrefix(c);
232+
}
233+
res.add(c);
234+
}
235+
return res;
236+
}
237+
238+
private String getPrefix(String comment) {
239+
if (comment.startsWith("# ")) {
240+
return "# ";
241+
} else if (comment.startsWith("#")) {
242+
return "#";
243+
} else if (comment.startsWith("! ")) {
244+
return "! ";
245+
} else if (comment.startsWith("!")) {
246+
return "!";
247+
} else {
248+
return "";
249+
}
250+
}
251+
252+
private List<Integer> findCommentLines(String key) {
253+
int idx = indexOf(key);
254+
return findCommentLines(idx);
255+
}
256+
257+
/**
258+
* Returns a list of token indices pointing to all the comment lines
259+
* in a comment block. A list of comments is considered a block when
260+
* they are consecutive lines, without any empty lines in between,
261+
* using the same comment symbol (so they are either all `!` comments
262+
* or all `#` ones).
263+
*/
264+
private List<Integer> findCommentLines(int idx) {
265+
List<Integer> result = new ArrayList<>();
266+
// Skip any preceding whitespace
267+
idx--;
268+
while (idx >= 0 && tokens.get(idx).getType() == PropertiesParser.Type.WHITESPACE) {
269+
idx--;
270+
}
271+
// Now find the first line of the comment block
272+
int commentSym = -1;
273+
PropertiesParser.Token token;
274+
while (idx >= 0 && (token = tokens.get(idx)).getType() == PropertiesParser.Type.COMMENT) {
275+
if (commentSym != -1 && commentSym != token.raw.charAt(0)) {
276+
// Comment doesn't start with the same comment symbol, so the block ends here
277+
break;
278+
} else {
279+
commentSym = token.raw.charAt(0);
280+
}
281+
result.add(0, idx);
282+
// Skip any preceding whitespace making sure to stop at EOL
283+
while (--idx >= 0 && !tokens.get(idx).isEol()) {}
284+
idx--;
285+
}
286+
return Collections.unmodifiableList(result);
287+
}
288+
289+
private int indexOf(String key) {
290+
return tokens.indexOf(new PropertiesParser.Token(PropertiesParser.Type.KEY, escape(key, true), key));
291+
}
292+
293+
private String escape(String raw, boolean forKey) {
294+
raw = raw.replace("\n", "\\n");
295+
raw = raw.replace("\r", "\\r");
296+
raw = raw.replace("\t", "\\t");
297+
raw = raw.replace("\f", "\\f");
298+
if (forKey) {
299+
raw = raw.replace(" ", "\\ ");
300+
} else {
301+
if (raw.charAt(raw.length() - 1) == ' ') {
302+
raw = raw.substring(0, raw.length() - 1) + "\\ ";
303+
}
304+
}
305+
raw = replace(raw, "[^\\x{0000}-\\x{00FF}]", m -> "\\\\u" + Integer.toString(m.group(0).charAt(0), 16));
306+
return raw;
307+
}
308+
309+
private static String replace(String input, String regex, Function<Matcher, String> callback) {
310+
return replace(input, Pattern.compile(regex), callback);
311+
}
312+
313+
private static String replace(String input, Pattern regex, Function<Matcher, String> callback) {
314+
StringBuffer resultString = new StringBuffer();
315+
Matcher regexMatcher = regex.matcher(input);
316+
while (regexMatcher.find()) {
317+
regexMatcher.appendReplacement(resultString, callback.apply(regexMatcher));
318+
}
319+
regexMatcher.appendTail(resultString);
320+
321+
return resultString.toString();
322+
}
323+
68324
public void load(Path file) throws IOException {
69325
try (Reader br = Files.newBufferedReader(file)) {
70326
load(br);

0 commit comments

Comments
 (0)