| IniSection.java |
1 /**
2 * Distribution License:
3 * JSword is free software; you can redistribute it and/or modify it under
4 * the terms of the GNU Lesser General Public License, version 2.1 or later
5 * as published by the Free Software Foundation. This program is distributed
6 * in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
7 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
8 * See the GNU Lesser General Public License for more details.
9 *
10 * The License is available on the internet at:
11 * http://www.gnu.org/copyleft/lgpl.html
12 * or by writing to:
13 * Free Software Foundation, Inc.
14 * 59 Temple Place - Suite 330
15 * Boston, MA 02111-1307, USA
16 *
17 * © CrossWire Bible Society, 2015 - 2016
18 */
19 package org.crosswire.common.util;
20
21 import java.io.BufferedReader;
22 import java.io.ByteArrayInputStream;
23 import java.io.File;
24 import java.io.FileInputStream;
25 import java.io.FileOutputStream;
26 import java.io.IOException;
27 import java.io.InputStream;
28 import java.io.InputStreamReader;
29 import java.io.OutputStreamWriter;
30 import java.io.PrintWriter;
31 import java.io.Reader;
32 import java.io.Writer;
33 import java.util.ArrayList;
34 import java.util.Collection;
35 import java.util.Collections;
36 import java.util.HashMap;
37 import java.util.Iterator;
38 import java.util.List;
39 import java.util.Map;
40
41 /**
42 * A utility class for a section of an INI style configuration file.
43 * Keys and values are maintained in insertion order. A key may have more than one value.
44 * <p>
45 * SWORD defines a conf as an INI file with one or more sections.
46 * Originally, all modules were described in a single conf, but
47 * now each module has its own conf.
48 * </p>
49 * <p>
50 * SWORD will be using a single conf to hold overrides for many
51 * modules. This is the motivation for this class as opposed to
52 * allowing only a single section as {@link IniSection}.
53 * </p>
54 * <p>
55 * Since the most common use case is for a single section, this
56 * implementation has an API for delegating to the first IniSection.
57 * </p>
58 *
59 * This implementation allows for:
60 * <ul>
61 * <li><strong>Case Insensitive</strong> -- Section names, keys and values are case insensitive.</li>
62 * <li><strong>Comments</strong> -- ; and # preceded only by white space indicate that a line is a comment.
63 * Note: SWORD does not support ; but it is present in some 3rd Party repositories such as IBT.</li>
64 * <li><strong>Multiple Values</strong> -- Each key can have one or more values.</li>
65 * <li><strong>Order</strong> -- Order of sections, keys and values are retained</li>
66 * </ul>
67 *
68 * This implementation does not allow for:
69 * <ul>
70 * <li><strong>Globals</strong> -- (key,value) pairs before the first section.</li>
71 * <li><strong>Quoted Values</strong> -- Values surrounded by "" or ''.
72 * If present they are part of the value.</li>
73 * <li><strong>Retaining comments</strong> -- Comments are ignored.</li>
74 * <li><strong>Comments after content</strong> -- Comments are on lines to themselves.</li>
75 * <li><strong>:</strong> -- as an alternative for =.</li>
76 * <li><strong>nulls</strong> -- null values.</li>
77 * </ul>
78 *
79
80 * @author DM Smith
81 * @see gnu.lgpl.License The GNU Lesser General Public License for details.<br>
82 */
83 public final class IniSection implements Iterable {
84
85 /**
86 * Create an empty INI config without a name.
87 */
88 public IniSection() {
89 this((String) null);
90 }
91 /**
92 * Create an empty INI Config.
93 * @param name the section name
94 */
95 public IniSection(String name) {
96 this.name = name;
97 section = new HashMap<String, List<String>>();
98 warnings = new StringBuilder();
99 }
100
101 /**
102 * Copy constructor
103 *
104 * @param config the config to copy
105 */
106 public IniSection(IniSection config) {
107 this.name = config.getName();
108 section = new HashMap<String, List<String>>();
109 for (String key : config.getKeys()) {
110 for (String value : config.getValues(key)) {
111 add(key, value);
112 }
113 }
114 }
115 /**
116 * Start over.
117 */
118 public void clear() {
119 section.clear();
120 warnings.setLength(0);
121 warnings.trimToSize();
122 report = "";
123 }
124
125 /**
126 * Set the name of this INI config.
127 *
128 * @param name
129 */
130 public void setName(String name) {
131 this.name = name;
132 }
133
134 /**
135 * Get the [name] of this section
136 *
137 * @return the name
138 */
139 public String getName() {
140 return name;
141 }
142
143 /**
144 * Get the number of keys in this section.
145 *
146 * @return the count
147 */
148 public int size() {
149 return section.size();
150 }
151
152 /**
153 * Get the number of values for a key.
154 *
155 * @param key the key
156 * @return the number of values for a key or 0 if the key does not exist.
157 */
158 public int size(String key) {
159 Collection<String> values = section.get(key);
160 return values == null ? 0 : values.size();
161 }
162
163 /**
164 * Determine whether this section has any keys
165 *
166 * @return {@code true} if this section is empty
167 */
168 public boolean isEmpty() {
169 return section.isEmpty();
170 }
171
172 public Iterator iterator() {
173 return section.keySet().iterator();
174 }
175 /**
176 * Get the unmodifiable unordered list of keys.
177 *
178 * @return the set of keys
179 */
180 public Collection<String> getKeys() {
181 return Collections.unmodifiableSet(section.keySet());
182 }
183
184 /**
185 * Returns {@code true} if the IniSection contains any values for the specified key.
186 *
187 * @param key key to search for in IniSection
188 * @return {@code true} if the key exists
189 */
190 public boolean containsKey(String key) {
191 return section.containsKey(key);
192 }
193
194 /**
195 * Returns {@code true} if the IniSection contains the specified value for any key.
196 *
197 * @param value value to search for in IniSection
198 * @return {@code true} if the value exists.
199 */
200 public boolean containsValue(String value) {
201 for (Collection<String> collection : section.values()) {
202 if (collection.contains(value)) {
203 return true;
204 }
205 }
206 return false;
207 }
208
209 /**
210 * Returns {@code true} if the IniSection contains the specified value for the given key.
211 *
212 * @param key the key for the section
213 * @param value value to search for in IniSection
214 * @return {@code true} if the value exists.
215 */
216 public boolean containsValue(String key, String value) {
217 Collection<String> values = section.get(key);
218 return values != null && values.contains(value);
219 }
220
221 /**
222 * Add a value for the key. Duplicate values are not allowed.
223 *
224 * @param key the key for the section
225 * @param value the value for the key
226 * @return whether the value was added or is already present.
227 */
228 public boolean add(String key, String value) {
229 if (!allowed(key, value)) {
230 return false;
231 }
232
233 Collection<String> values = getOrCreateValues(key);
234 if (values.contains(value)) {
235 warnings.append("Duplicate value: ").append(key).append(" = ").append(value).append('\n');
236 return true;
237 }
238 return values.add(value);
239 }
240
241 /**
242 * Get the unmodifiable collection of values of a key.
243 * The collection has insertion order.
244 * Note many keys only have one value.
245 * A key that has no values returns null.
246 *
247 * @param key the key
248 * @return the keyed values or null if the key doesn't exist
249 */
250 public Collection<String> getValues(String key) {
251 if (section.containsKey(key)) {
252 return Collections.unmodifiableCollection(section.get(key));
253 }
254 return null;
255 }
256
257 /**
258 * Get the value for the key specified by the index.
259 *
260 * @param key the key
261 * @param index the index
262 * @return the value at the specified index
263 * @throws ArrayIndexOutOfBoundsException when the index is out of bounds
264 */
265 public String get(String key, int index) {
266 List<String> values = section.get(key);
267 return values == null ? null : values.get(index);
268 }
269
270 /**
271 * Get the first value for the key.
272 *
273 * @param key the key
274 * @return the value at the specified index or null
275 */
276 public String get(String key) {
277 List<String> values = section.get(key);
278 return values == null ? null : values.get(0);
279 }
280
281 public String get(String key, String defaultValue) {
282 List<String> values = section.get(key);
283 return values == null ? defaultValue : values.get(0);
284 }
285
286 /**
287 * Remove the value if present.
288 * If it were the last value for the key, the key is removed.
289 *
290 * @param key the key for the section
291 * @param value the value for the key
292 * @return whether the value was present and removed
293 */
294 public boolean remove(String key, String value) {
295 Collection<String> values = section.get(key);
296 if (values == null) {
297 return false;
298 }
299
300 boolean changed = values.remove(value);
301 if (changed) {
302 if (values.isEmpty()) {
303 section.remove(key);
304 }
305 }
306
307 return changed;
308 }
309
310 /**
311 * Remove the key and all its values, if present.
312 *
313 * @param key the key for the section
314 * @return whether the key was present and removed
315 */
316 public boolean remove(String key) {
317 Collection<String> values = section.get(key);
318 if (values == null) {
319 return false;
320 }
321 section.remove(key);
322 return true;
323 }
324
325 /**
326 * Replace the value(s) for the key with a new value.
327 *
328 * @param key the key for the section
329 * @param value the value for the key
330 * @return whether the replace happened
331 */
332 public boolean replace(String key, String value) {
333 if (!allowed(key, value)) {
334 return false;
335 }
336
337 Collection<String> values = getOrCreateValues(key);
338 values.clear();
339 return values.add(value);
340 }
341
342 /**
343 * Load the INI from an InputStream using the given encoding.
344 *
345 * @param is the InputStream to read from
346 * @param encoding the encoding of the file
347 * @throws IOException
348 */
349 public void load(InputStream is, String encoding) throws IOException {
350 load(is, encoding, null);
351 }
352
353 /**
354 * Load the INI from an InputStream using the given encoding. Filter keys as specified.
355 *
356 * @param is the InputStream to read from
357 * @param encoding the encoding of the file
358 * @param filter the filter, possibly null, for the desired keys
359 * @throws IOException
360 */
361 public void load(InputStream is, String encoding, Filter<String> filter) throws IOException {
362 Reader in = null;
363 try {
364 in = new InputStreamReader(is, encoding);
365 doLoad(in, filter);
366 } finally {
367 if (in != null) {
368 in.close();
369 in = null;
370 }
371 }
372 }
373
374 /**
375 * Load the INI from a file using the given encoding.
376 *
377 * @param file the file to load
378 * @param encoding the encoding of the file
379 * @throws IOException
380 */
381 public void load(File file, String encoding) throws IOException {
382 load(file, encoding, null);
383 }
384
385 /**
386 * Load the INI from a file using the given encoding. Filter keys as specified.
387 *
388 * @param file the file to load
389 * @param encoding the encoding of the file
390 * @param filter the filter, possibly null, for the desired keys
391 * @throws IOException
392 */
393 public void load(File file, String encoding, Filter<String> filter) throws IOException {
394 this.configFile = file;
395 this.charset = encoding;
396 InputStream in = null;
397 try {
398 in = new FileInputStream(file);
399 load(in, encoding, filter);
400 } finally {
401 if (in != null) {
402 in.close();
403 in = null;
404 }
405 }
406 }
407
408 /**
409 * Load the conf from a buffer. This is used to load conf entries from the
410 * mods.d.tar.gz file.
411 *
412 * @param buffer the buffer to load
413 * @param encoding the character encoding of this INI
414 * @throws IOException
415 */
416 public void load(byte[] buffer, String encoding) throws IOException {
417 load(buffer, encoding, null);
418 }
419
420 /**
421 * Load the conf from a buffer. Filter keys as specified.
422 * This is used to load conf entries from the mods.d.tar.gz file.
423 *
424 * @param buffer the buffer to load
425 * @param encoding the character encoding of this INI
426 * @param filter the filter, possibly null, for the desired keys
427 * @throws IOException
428 */
429 public void load(byte[] buffer, String encoding, Filter<String> filter) throws IOException {
430 InputStream in = null;
431 try {
432 in = new ByteArrayInputStream(buffer);
433 load(in, encoding, filter);
434 } finally {
435 if (in != null) {
436 in.close();
437 in = null;
438 }
439 }
440 }
441
442 /**
443 * Save this INI to the file from which it was loaded.
444 * @throws IOException
445 */
446 public void save() throws IOException {
447 assert configFile != null;
448 assert charset != null;
449 if (configFile != null && charset != null) {
450 save(configFile, charset);
451 }
452 }
453
454 /**
455 * Save the INI to a file using the given encoding.
456 *
457 * @param file the file to load
458 * @param encoding the encoding of the file
459 * @throws IOException
460 */
461 public void save(File file, String encoding) throws IOException {
462 this.configFile = file;
463 this.charset = encoding;
464 Writer out = null;
465 try {
466 out = new OutputStreamWriter(new FileOutputStream(file), encoding);
467 save(out);
468 } finally {
469 if (out != null) {
470 out.close();
471 out = null;
472 }
473 }
474 }
475
476 /**
477 * Output this section using the print writer. The section ends with a blank line.
478 * The items are output in insertion order.
479 *
480 * @param out the output stream
481 */
482 public void save(Writer out) {
483 PrintWriter writer = null;
484 if (out instanceof PrintWriter) {
485 writer = (PrintWriter) out;
486 } else {
487 writer = new PrintWriter(out);
488 }
489
490 writer.print("[");
491 writer.print(name);
492 writer.print("]");
493 writer.println();
494
495 boolean first = true;
496 Iterator<String> keys = section.keySet().iterator();
497 while (keys.hasNext()) {
498 String key = keys.next();
499 Collection<String> values = section.get(key);
500 Iterator<String> iter = values.iterator();
501 String value;
502 while (iter.hasNext()) {
503 if (!first) {
504 writer.println();
505 first = false;
506 }
507 value = iter.next();
508 writer.print(key);
509 writer.print(" = ");
510 writer.print(format(value));
511 writer.println();
512 }
513 }
514
515 writer.flush();
516 }
517
518 /**
519 * Obtain a report of issues with this IniSection. It only reports once per load.
520 *
521 * @return the report with one issue per line or an empty string if there are no issues
522 */
523 public String report() {
524 String str = report;
525 report = "";
526 return str;
527 }
528
529 /**
530 * A helper to format the output of the content as expected
531 * @param value the value to be formatted
532 * @return the transformed value
533 */
534 private String format(final String value) {
535 // Find continuations and replace newlines with a ' \'
536 // Indenting the next line
537 // Note: if the quoting of values is allowed this may need to be revisited.
538 return value.replaceAll("\n", " \\\\\n\t");
539 }
540
541 private Collection<String> getOrCreateValues(final String key) {
542 List<String> values = section.get(key);
543 if (values == null) {
544 values = new ArrayList<String>();
545 section.put(key, values);
546 }
547 return values;
548 }
549
550 private void doLoad(Reader in, Filter<String> filter) throws IOException {
551 BufferedReader bin = null;
552 try {
553 if (in instanceof BufferedReader) {
554 bin = (BufferedReader) in;
555 } else {
556 // Quiet Android from complaining about using the default
557 // BufferReader buffer size.
558 // The actual buffer size is undocumented. So this is a good
559 // idea any way.
560 bin = new BufferedReader(in, MAX_BUFF_SIZE);
561 }
562
563 while (true) {
564 String line = advance(bin);
565 if (line == null) {
566 break;
567 }
568
569 if (isSectionLine(line)) {
570 // The conf file contains a leading line of the form [KJV]
571 // This is the acronym by which Sword refers to it.
572 name = line.substring(1, line.length() - 1);
573 continue;
574 }
575
576 // Is this a key line?
577 int splitPos = getSplitPos(line);
578 if (splitPos < 0) {
579 warnings.append("Skipping: Expected to see '=' in: ").append(line).append('\n');
580 continue;
581 }
582
583 String key = line.substring(0, splitPos).trim();
584 String value = more(bin, line.substring(splitPos + 1).trim());
585 if (filter == null || filter.test(key)) {
586 add(key, value);
587 }
588 }
589 report = warnings.toString();
590 warnings.setLength(0);
591 warnings.trimToSize();
592 } finally {
593 if (bin != null) {
594 bin.close();
595 bin = null;
596 }
597 }
598 }
599
600 /**
601 * Get the next line from the input
602 *
603 * @param bin The reader to get data from
604 * @return the next line or null if there is nothing more
605 * @throws IOException if encountered
606 */
607 private String advance(BufferedReader bin) throws IOException {
608 // Get the next non-blank, non-comment line
609 String trimmed = null;
610 for (String line = bin.readLine(); line != null; line = bin.readLine()) {
611 // Remove leading and trailing whitespace
612 trimmed = line.trim();
613
614 // skip blank and comment lines
615 if (!isCommentLine(trimmed)) {
616 return trimmed;
617 }
618 }
619 return null;
620 }
621
622 /**
623 * Determine if the given line is a blank or a comment line.
624 *
625 * @param line The line to check.
626 * @return true if the line is empty or starts with one of the comment
627 * characters
628 */
629 private boolean isCommentLine(final String line) {
630 if (line == null) {
631 return false;
632 }
633 if (line.length() == 0) {
634 return true;
635 }
636 char firstChar = line.charAt(0);
637 return firstChar == ';' || firstChar == '#';
638 }
639
640 /**
641 * Is this line a [section]?
642 *
643 * @param line The line to check.
644 * @return true if the line designates a section
645 */
646 private boolean isSectionLine(final String line) {
647 return line.charAt(0) == '[' && line.charAt(line.length() - 1) == ']';
648 }
649
650 /**
651 * Does this line of text represent a key/value pair?
652 *
653 * @param line The line to check.
654 * @return the position of the split position or -1
655 */
656 private int getSplitPos(final String line) {
657 return line.indexOf('=');
658 }
659
660 /**
661 * Get continuation lines, if any.
662 */
663 private String more(BufferedReader bin, String value) throws IOException {
664 boolean moreCowBell = false;
665 String line = value;
666 StringBuilder buf = new StringBuilder();
667
668 do {
669 moreCowBell = more(line);
670 if (moreCowBell) {
671 line = line.substring(0, line.length() - 1).trim();
672 }
673 buf.append(line);
674 if (moreCowBell) {
675 buf.append('\n');
676 line = advance(bin);
677 // Is this new line a potential key line?
678 // It cannot both continue the prior
679 // and also be a key line.
680 int splitPos = getSplitPos(line);
681 if (splitPos >= 0) {
682 warnings.append("Possible trailing continuation on previous line. Found: ").append(line).append('\n');
683 }
684 }
685 } while (moreCowBell && line != null);
686 String cowBell = buf.toString();
687 buf = null;
688 line = null;
689 return cowBell;
690 }
691
692 /**
693 * Is there more following this line
694 *
695 * @param line the trimmed string to check
696 * @return whether this line continues
697 */
698 private static boolean more(final String line) {
699 int length = line.length();
700 return length > 0 && line.charAt(length - 1) == '\\';
701 }
702
703 private boolean allowed(String key, String value) {
704 if (key == null || key.length() == 0 || value == null) {
705 if (key == null) {
706 warnings.append("Null keys not allowed: ").append(" = ").append(value).append('\n');
707 } else if (key.length() == 0) {
708 warnings.append("Empty keys not allowed: ").append(" = ").append(value).append('\n');
709 }
710 if (value == null) {
711 warnings.append("Null values are not allowed: ").append(key).append(" = ").append('\n');
712 }
713 return false;
714 }
715 return true;
716 }
717
718 /**
719 * The name of the section.
720 */
721 private String name;
722
723 /**
724 * A map of values by key names.
725 */
726 private Map<String, List<String>> section;
727
728 private File configFile;
729
730 private String charset;
731
732 private StringBuilder warnings;
733
734 private String report;
735
736 /**
737 * Buffer size is based on file size but keep it with within reasonable limits
738 */
739 private static final int MAX_BUFF_SIZE = 2 * 1024;
740 }
741