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