1010import java .util .AbstractMap ;
1111import java .util .AbstractSet ;
1212import java .util .ArrayList ;
13+ import java .util .Arrays ;
14+ import java .util .Collection ;
15+ import java .util .Collections ;
1316import java .util .Iterator ;
1417import java .util .LinkedHashMap ;
18+ import java .util .LinkedHashSet ;
1519import java .util .List ;
20+ import java .util .NoSuchElementException ;
1621import java .util .Set ;
22+ import java .util .function .Function ;
23+ import java .util .regex .Matcher ;
24+ import java .util .regex .Pattern ;
1725import java .util .stream .Collectors ;
26+ import java .util .stream .IntStream ;
27+
28+ import static org .codejive .properties .PropertiesParser .unescape ;
1829
1930public 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