/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.felix.utils.properties; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FilterWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.LineNumberReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; import java.net.URL; import java.security.AccessController; import java.security.PrivilegedAction; import java.util.AbstractMap; import java.util.AbstractSet; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import org.osgi.framework.BundleContext; /** * <p> * Enhancement of the standard <code>Properties</code> * managing the maintain of comments, etc. * </p> * * @author gnodet, jbonofre */ public class Properties extends AbstractMap<String, String> { /** Constant for the supported comment characters.*/ private static final String COMMENT_CHARS = "#!"; /** The list of possible key/value separators */ private static final char[] SEPARATORS = new char[] {'=', ':'}; /** The white space characters used as key/value separators. */ private static final char[] WHITE_SPACE = new char[] {' ', '\t', '\f'}; /** * The default encoding (ISO-8859-1 as specified by * http://java.sun.com/j2se/1.5.0/docs/api/java/util/Properties.html) */ static final String DEFAULT_ENCODING = "ISO-8859-1"; /** Constant for the platform specific line separator.*/ private static final String LINE_SEPARATOR = AccessController.doPrivileged(new PrivilegedAction<String>() { public String run() { return System.getProperty("line.separator"); } }); /** Constant for the radix of hex numbers.*/ private static final int HEX_RADIX = 16; /** Constant for the length of a unicode literal.*/ private static final int UNICODE_LEN = 4; private final Map<String,String> storage = new LinkedHashMap<String,String>(); private final Map<String,Layout> layout = new LinkedHashMap<String,Layout>(); private List<String> header; private List<String> footer; private File location; private InterpolationHelper.SubstitutionCallback callback; boolean substitute = true; boolean typed; public Properties() { } public Properties(File location) throws IOException { this(location, (InterpolationHelper.SubstitutionCallback) null); } public Properties(File location, BundleContext context) throws IOException { this(location, new InterpolationHelper.BundleContextSubstitutionCallback(context)); } public Properties(File location, InterpolationHelper.SubstitutionCallback callback) throws IOException { this.location = location; this.callback = callback; if(location.exists()) load(location); } public Properties(boolean substitute) { this.substitute = substitute; } public Properties(File location, boolean substitute) { this.location = location; this.substitute = substitute; } public void load(File location) throws IOException { InputStream is = new FileInputStream(location); try { load(is); } finally { is.close(); } } public void load(URL location) throws IOException { InputStream is = location.openStream(); try { load(is); } finally { is.close(); } } public void load(InputStream is) throws IOException { load(new InputStreamReader(is, DEFAULT_ENCODING)); } public void load(Reader reader) throws IOException { loadLayout(reader, false); } public void save() throws IOException { save(this.location); } public void save(File location) throws IOException { OutputStream os = new FileOutputStream(location); try { save(os); } finally { os.close(); } } public void save(OutputStream os) throws IOException { save(new OutputStreamWriter(os, DEFAULT_ENCODING)); } public void save(Writer writer) throws IOException { saveLayout(writer, typed); } /** * Store a properties into a output stream, preserving comments, special character, etc. * This method is mainly to be compatible with the java.util.Properties class. * * @param os an output stream. * @param comment this parameter is ignored as this Properties * @throws IOException If storing fails */ public void store(OutputStream os, String comment) throws IOException { this.save(os); } /** * Searches for the property with the specified key in this property list. * * @param key the property key. * @return the value in this property list with the specified key value. */ public String getProperty(String key) { return this.get(key); } /** * Searches for the property with the specified key in this property list. If the key is not found in this property * list, the default property list, and its defaults, recursively, are then checked. The method returns the default * value argument if the property is not found. * * @param key the property key. * @param defaultValue a default value. * @return The property value of the default value */ public String getProperty(String key, String defaultValue) { if (this.get(key) != null) return this.get(key); return defaultValue; } @Override public Set<Entry<String, String>> entrySet() { return new AbstractSet<Entry<String, String>>() { @Override public Iterator<Entry<String, String>> iterator() { return new Iterator<Entry<String, String>>() { final Iterator<Entry<String, String>> keyIterator = storage.entrySet().iterator(); public boolean hasNext() { return keyIterator.hasNext(); } public Entry<String, String> next() { final Entry<String, String> entry = keyIterator.next(); return new Entry<String, String>() { public String getKey() { return entry.getKey(); } public String getValue() { return entry.getValue(); } public String setValue(String value) { String old = entry.setValue(value); if (old == null || !old.equals(value)) { Layout l = layout.get(entry.getKey()); if (l != null) { l.clearValue(); } } return old; } }; } public void remove() { keyIterator.remove(); } }; } @Override public int size() { return storage.size(); } }; } /** * Returns an enumeration of all the keys in this property list, including distinct keys in the default property * list if a key of the same name has not already been found from the main properties list. * * @return an enumeration of all the keys in this property list, including the keys in the default property list. */ public Enumeration<?> propertyNames() { return Collections.enumeration(storage.keySet()); } /** * Calls the map method put. Provided for parallelism with the getProperty method. * Enforces use of strings for property keys and values. The value returned is the result of the map call to put. * * @param key the key to be placed into this property list. * @param value the value corresponding to the key. * @return the previous value of the specified key in this property list, or null if it did not have one. */ public Object setProperty(String key, String value) { return this.put(key, value); } @Override public String put(String key, String value) { String old = storage.put(key, value); if (old == null || !old.equals(value)) { Layout l = layout.get(key); if (l != null) { l.clearValue(); } } return old; } public String put(String key, List<String> commentLines, List<String> valueLines) { commentLines = new ArrayList<String>(commentLines); valueLines = new ArrayList<String>(valueLines); String escapedKey = escapeKey(key); StringBuilder sb = new StringBuilder(); int lastLine = valueLines.size() - 1; if (valueLines.isEmpty()) { valueLines.add(escapedKey + "="); sb.append(escapedKey).append("="); } else { String val0 = valueLines.get(0); String rv0 = typed ? val0 : escapeJava(val0); if (!val0.trim().startsWith(escapedKey)) { valueLines.set(0, escapedKey + " = " + rv0 /*+ (0 < lastLine? "\\": "")*/); sb.append(escapedKey).append(" = ").append(rv0); } else { valueLines.set(0, rv0 /*+ (0 < lastLine? "\\": "")*/); sb.append(rv0); } } for (int i = 1; i < valueLines.size(); i++) { String val = valueLines.get(i); valueLines.set(i, typed ? val : escapeJava(val) /*+ (i < lastLine? "\\": "")*/); while (val.length() > 0 && Character.isWhitespace(val.charAt(0))) { val = val.substring(1); } sb.append(val); } String[] property = PropertiesReader.parseProperty(sb.toString()); this.layout.put(key, new Layout(commentLines, valueLines)); return storage.put(key, property[1]); } public String put(String key, List<String> commentLines, String value) { commentLines = new ArrayList<String>(commentLines); this.layout.put(key, new Layout(commentLines, null)); return storage.put(key, value); } public String put(String key, String comment, String value) { return put(key, Collections.singletonList(comment), value); } public boolean update(Map<String, String> props) { Properties properties; if (props instanceof Properties) { properties = (Properties) props; } else { properties = new Properties(); for (Map.Entry<? extends String, ? extends String> e : props.entrySet()) { properties.put(e.getKey(), e.getValue()); } } return update(properties); } public boolean update(Properties properties) { boolean modified = false; // Remove "removed" properties from the cfg file for (String key : new ArrayList<String>(this.keySet())) { if (!properties.containsKey(key)) { this.remove(key); modified = true; } } // Update existing keys for (String key : properties.keySet()) { String v = this.get(key); List<String> comments = properties.getComments(key); List<String> value = properties.getRaw(key); if (v == null) { this.put(key, comments, value); modified = true; } else if (!v.equals(properties.get(key))) { if (comments.isEmpty()) { comments = this.getComments(key); } this.put(key, comments, value); modified = true; } } return modified; } public List<String> getRaw(String key) { if (layout.containsKey(key)) { if (layout.get(key).getValueLines() != null) { return new ArrayList<String>(layout.get(key).getValueLines()); } } List<String> result = new ArrayList<String>(); if (storage.containsKey(key)) { result.add(storage.get(key)); } return result; } public List<String> getComments(String key) { if (layout.containsKey(key)) { if (layout.get(key).getCommentLines() != null) { return new ArrayList<String>(layout.get(key).getCommentLines()); } } return new ArrayList<String>(); } @Override public String remove(Object key) { Layout l = layout.get(key); if (l != null) { l.clearValue(); } return storage.remove(key); } @Override public void clear() { for (Layout l : layout.values()) { l.clearValue(); } storage.clear(); } /** * Return the comment header. * * @return the comment header */ public List<String> getHeader() { return header; } /** * Set the comment header. * * @param header the header to use */ public void setHeader(List<String> header) { this.header = header; } /** * Return the comment footer. * * @return the comment footer */ public List<String> getFooter() { return footer; } /** * Set the comment footer. * * @param footer the footer to use */ public void setFooter(List<String> footer) { this.footer = footer; } /** * Reads a properties file and stores its internal structure. The found * properties will be added to the associated configuration object. * * @param in the reader to the properties file * @throws java.io.IOException if an error occurs */ protected void loadLayout(Reader in, boolean maybeTyped) throws IOException { PropertiesReader reader = new PropertiesReader(in, maybeTyped); boolean hasProperty = false; while (reader.nextProperty()) { hasProperty = true; storage.put(reader.getPropertyName(), reader.getPropertyValue()); int idx = checkHeaderComment(reader.getCommentLines()); layout.put(reader.getPropertyName(), new Layout(idx < reader.getCommentLines().size() ? new ArrayList<String>(reader.getCommentLines().subList(idx, reader.getCommentLines().size())) : null, new ArrayList<String>(reader.getValueLines()))); } typed = maybeTyped && reader.typed != null && reader.typed; if (!typed) { for (Map.Entry<String,String> e : storage.entrySet()) { e.setValue(unescapeJava(e.getValue())); } } if (hasProperty) { footer = new ArrayList<String>(reader.getCommentLines()); } else { header = new ArrayList<String>(reader.getCommentLines()); } if (substitute) { substitute(); } } public void substitute() { substitute(callback); } public void substitute(InterpolationHelper.SubstitutionCallback callback) { if (callback == null) { callback = new InterpolationHelper.BundleContextSubstitutionCallback(null); } InterpolationHelper.performSubstitution(storage, callback); } /** * Writes the properties file to the given writer, preserving as much of its * structure as possible. * * @param out the writer * @throws java.io.IOException if an error occurs */ protected void saveLayout(Writer out, boolean typed) throws IOException { PropertiesWriter writer = new PropertiesWriter(out, typed); if (header != null) { for (String s : header) { writer.writeln(s); } } for (String key : storage.keySet()) { Layout l = layout.get(key); if (l != null && l.getCommentLines() != null) { for (String s : l.getCommentLines()) { writer.writeln(s); } } if (l != null && l.getValueLines() != null) { for (int i = 0; i < l.getValueLines().size(); i++) { String s = l.getValueLines().get(i); if (i < l.getValueLines().size() - 1) { writer.writeln(s + "\\"); } else { writer.writeln(s); } } } else { writer.writeProperty(key, storage.get(key)); } } if (footer != null) { for (String s : footer) { writer.writeln(s); } } writer.flush(); } /** * Checks if parts of the passed in comment can be used as header comment. * This method checks whether a header comment can be defined (i.e. whether * this is the first comment in the loaded file). If this is the case, it is * searched for the lates blank line. This line will mark the end of the * header comment. The return value is the index of the first line in the * passed in list, which does not belong to the header comment. * * @param commentLines the comment lines * @return the index of the next line after the header comment */ private int checkHeaderComment(List<String> commentLines) { if (getHeader() == null && layout.isEmpty()) { // This is the first comment. Search for blank lines. int index = commentLines.size() - 1; while (index >= 0 && commentLines.get(index).length() > 0) { index--; } setHeader(new ArrayList<String>(commentLines.subList(0, index + 1))); return index + 1; } else { return 0; } } /** * Tests whether a line is a comment, i.e. whether it starts with a comment * character. * * @param line the line * @return a flag if this is a comment line */ static boolean isCommentLine(String line) { String s = line.trim(); // blank lines are also treated as comment lines return s.length() < 1 || COMMENT_CHARS.indexOf(s.charAt(0)) >= 0; } /** * <p>Unescapes any Java literals found in the <code>String</code> to a * <code>Writer</code>.</p> This is a slightly modified version of the * StringEscapeUtils.unescapeJava() function in commons-lang that doesn't * drop escaped separators (i.e '\,'). * * @param str the <code>String</code> to unescape, may be null * @return the processed string * @throws IllegalArgumentException if the Writer is <code>null</code> */ protected static String unescapeJava(String str) { if (str == null) { return null; } int sz = str.length(); StringBuffer out = new StringBuffer(sz); StringBuffer unicode = new StringBuffer(UNICODE_LEN); boolean hadSlash = false; boolean inUnicode = false; for (int i = 0; i < sz; i++) { char ch = str.charAt(i); if (inUnicode) { // if in unicode, then we're reading unicode // values in somehow unicode.append(ch); if (unicode.length() == UNICODE_LEN) { // unicode now contains the four hex digits // which represents our unicode character try { int value = Integer.parseInt(unicode.toString(), HEX_RADIX); out.append((char) value); unicode.setLength(0); inUnicode = false; hadSlash = false; } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Unable to parse unicode value: " + unicode, nfe); } } continue; } if (hadSlash) { // handle an escaped value hadSlash = false; switch (ch) { case '\\' : out.append('\\'); break; case '\'' : out.append('\''); break; case '\"' : out.append('"'); break; case 'r' : out.append('\r'); break; case 'f' : out.append('\f'); break; case 't' : out.append('\t'); break; case 'n' : out.append('\n'); break; case 'b' : out.append('\b'); break; case 'u' : // uh-oh, we're in unicode country.... inUnicode = true; break; default : out.append(ch); break; } continue; } else if (ch == '\\') { hadSlash = true; continue; } out.append(ch); } if (hadSlash) { // then we're in the weird case of a \ at the end of the // string, let's output it anyway. out.append('\\'); } return out.toString(); } /** * <p>Escapes the characters in a <code>String</code> using Java String rules.</p> * * <p>Deals correctly with quotes and control-chars (tab, backslash, cr, ff, etc.) </p> * * <p>So a tab becomes the characters <code>'\\'</code> and * <code>'t'</code>.</p> * * <p>The only difference between Java strings and JavaScript strings * is that in JavaScript, a single quote must be escaped.</p> * * <p>Example:</p> * <pre> * input string: He didn't say, "Stop!" * output string: He didn't say, \"Stop!\" * </pre> * * * @param str String to escape values in, may be null * @return String with escaped values, <code>null</code> if null string input */ protected static String escapeJava(String str) { if (str == null) { return null; } int sz = str.length(); StringBuffer out = new StringBuffer(sz * 2); for (int i = 0; i < sz; i++) { char ch = str.charAt(i); // handle unicode if (ch > 0xfff) { out.append("\\u").append(hex(ch)); } else if (ch > 0xff) { out.append("\\u0").append(hex(ch)); } else if (ch > 0x7f) { out.append("\\u00").append(hex(ch)); } else if (ch < 32) { switch (ch) { case '\b' : out.append('\\'); out.append('b'); break; case '\n' : out.append('\\'); out.append('n'); break; case '\t' : out.append('\\'); out.append('t'); break; case '\f' : out.append('\\'); out.append('f'); break; case '\r' : out.append('\\'); out.append('r'); break; default : if (ch > 0xf) { out.append("\\u00").append(hex(ch)); } else { out.append("\\u000").append(hex(ch)); } break; } } else { switch (ch) { case '"' : out.append('\\'); out.append('"'); break; case '\\' : out.append('\\'); out.append('\\'); break; default : out.append(ch); break; } } } return out.toString(); } /** * <p>Returns an upper case hexadecimal <code>String</code> for the given * character.</p> * * @param ch The character to convert. * @return An upper case hexadecimal <code>String</code> */ protected static String hex(char ch) { return Integer.toHexString(ch).toUpperCase(Locale.ENGLISH); } /** * <p>Checks if the value is in the given array.</p> * * <p>The method returns <code>false</code> if a <code>null</code> array is passed in.</p> * * @param array the array to search through * @param valueToFind the value to find * @return <code>true</code> if the array contains the object */ public static boolean contains(char[] array, char valueToFind) { if (array == null) { return false; } for (int i = 0; i < array.length; i++) { if (valueToFind == array[i]) { return true; } } return false; } /** * Escape the separators in the key. * * @param key the key * @return the escaped key */ private static String escapeKey(String key) { StringBuffer newkey = new StringBuffer(); for (int i = 0; i < key.length(); i++) { char c = key.charAt(i); if (contains(SEPARATORS, c) || contains(WHITE_SPACE, c)) { // escape the separator newkey.append('\\'); newkey.append(c); } else { newkey.append(c); } } return newkey.toString(); } /** * This class is used to read properties lines. These lines do * not terminate with new-line chars but rather when there is no * backslash sign a the end of the line. This is used to * concatenate multiple lines for readability. */ public static class PropertiesReader extends LineNumberReader { /** Stores the comment lines for the currently processed property.*/ private final List<String> commentLines; /** Stores the value lines for the currently processed property.*/ private final List<String> valueLines; /** Stores the name of the last read property.*/ private String propertyName; /** Stores the value of the last read property.*/ private String propertyValue; private boolean maybeTyped; /** Stores if the properties are typed or not */ Boolean typed; /** * Creates a new instance of <code>PropertiesReader</code> and sets * the underlaying reader and the list delimiter. * * @param reader the reader */ public PropertiesReader(Reader reader, boolean maybeTyped) { super(reader); commentLines = new ArrayList<String>(); valueLines = new ArrayList<String>(); this.maybeTyped = maybeTyped; } /** * Reads a property line. Returns null if Stream is * at EOF. Concatenates lines ending with "\". * Skips lines beginning with "#" or "!" and empty lines. * The return value is a property definition (<code><name></code> * = <code><value></code>) * * @return A string containing a property value or null * * @throws java.io.IOException in case of an I/O error */ public String readProperty() throws IOException { commentLines.clear(); valueLines.clear(); StringBuffer buffer = new StringBuffer(); while (true) { String line = readLine(); if (line == null) { // EOF return null; } if (isCommentLine(line)) { commentLines.add(line); continue; } boolean combine = checkCombineLines(line); if (combine) { line = line.substring(0, line.length() - 1); } valueLines.add(line); while (line.length() > 0 && contains(WHITE_SPACE, line.charAt(0))) { line = line.substring(1, line.length()); } buffer.append(line); if (!combine) { break; } } return buffer.toString(); } /** * Parses the next property from the input stream and stores the found * name and value in internal fields. These fields can be obtained using * the provided getter methods. The return value indicates whether EOF * was reached (<b>false</b>) or whether further properties are * available (<b>true</b>). * * @return a flag if further properties are available * @throws java.io.IOException if an error occurs */ public boolean nextProperty() throws IOException { String line = readProperty(); if (line == null) { return false; // EOF } // parse the line String[] property = parseProperty(line); boolean typed = false; if (maybeTyped && property[1].length() >= 2) { typed = property[1].matches("\\s*[TILFDXSCBilfdxscb]?(\\[[\\S\\s]*\\]|\\{[\\S\\s]*\\}|\"[\\S\\s]*\")\\s*"); } if (this.typed == null) { this.typed = typed; } else { this.typed = this.typed & typed; } propertyName = unescapeJava(property[0]); propertyValue = property[1]; return true; } /** * Returns the comment lines that have been read for the last property. * * @return the comment lines for the last property returned by * <code>readProperty()</code> */ public List<String> getCommentLines() { return commentLines; } /** * Returns the value lines that have been read for the last property. * * @return the raw value lines for the last property returned by * <code>readProperty()</code> */ public List<String> getValueLines() { return valueLines; } /** * Returns the name of the last read property. This method can be called * after <code>{@link #nextProperty()}</code> was invoked and its * return value was <b>true</b>. * * @return the name of the last read property */ public String getPropertyName() { return propertyName; } /** * Returns the value of the last read property. This method can be * called after <code>{@link #nextProperty()}</code> was invoked and * its return value was <b>true</b>. * * @return the value of the last read property */ public String getPropertyValue() { return propertyValue; } /** * Checks if the passed in line should be combined with the following. * This is true, if the line ends with an odd number of backslashes. * * @param line the line * @return a flag if the lines should be combined */ private static boolean checkCombineLines(String line) { int bsCount = 0; for (int idx = line.length() - 1; idx >= 0 && line.charAt(idx) == '\\'; idx--) { bsCount++; } return bsCount % 2 != 0; } /** * Parse a property line and return the key and the value in an array. * * @param line the line to parse * @return an array with the property's key and value */ private static String[] parseProperty(String line) { // sorry for this spaghetti code, please replace it as soon as // possible with a regexp when the Java 1.3 requirement is dropped String[] result = new String[2]; StringBuffer key = new StringBuffer(); StringBuffer value = new StringBuffer(); // state of the automaton: // 0: key parsing // 1: antislash found while parsing the key // 2: separator crossing // 3: white spaces // 4: value parsing int state = 0; for (int pos = 0; pos < line.length(); pos++) { char c = line.charAt(pos); switch (state) { case 0: if (c == '\\') { state = 1; } else if (contains(WHITE_SPACE, c)) { // switch to the separator crossing state state = 2; } else if (contains(SEPARATORS, c)) { // switch to the value parsing state state = 3; } else { key.append(c); } break; case 1: if (contains(SEPARATORS, c) || contains(WHITE_SPACE, c)) { // this is an escaped separator or white space key.append(c); } else { // another escaped character, the '\' is preserved key.append('\\'); key.append(c); } // return to the key parsing state state = 0; break; case 2: if (contains(WHITE_SPACE, c)) { // do nothing, eat all white spaces state = 2; } else if (contains(SEPARATORS, c)) { // switch to the value parsing state state = 3; } else { // any other character indicates we encoutered the beginning of the value value.append(c); // switch to the value parsing state state = 4; } break; case 3: if (contains(WHITE_SPACE, c)) { // do nothing, eat all white spaces state = 3; } else { // any other character indicates we encoutered the beginning of the value value.append(c); // switch to the value parsing state state = 4; } break; case 4: value.append(c); break; } } result[0] = key.toString(); result[1] = value.toString(); return result; } } // class PropertiesReader /** * This class is used to write properties lines. */ public static class PropertiesWriter extends FilterWriter { private boolean typed; /** * Constructor. * * @param writer a Writer object providing the underlying stream */ public PropertiesWriter(Writer writer, boolean typed) { super(writer); this.typed = typed; } /** * Writes the given property and its value. * * @param key the property key * @param value the property value * @throws java.io.IOException if an error occurs */ public void writeProperty(String key, String value) throws IOException { write(escapeKey(key)); write(" = "); write(typed ? value : escapeJava(value)); writeln(null); } /** * Helper method for writing a line with the platform specific line * ending. * * @param s the content of the line (may be <b>null</b>) * @throws java.io.IOException if an error occurs */ public void writeln(String s) throws IOException { if (s != null) { write(s); } write(LINE_SEPARATOR); } } // class PropertiesWriter /** * TODO */ protected static class Layout { private List<String> commentLines; private List<String> valueLines; public Layout() { } public Layout(List<String> commentLines, List<String> valueLines) { this.commentLines = commentLines; this.valueLines = valueLines; } public List<String> getCommentLines() { return commentLines; } public void setCommentLines(List<String> commentLines) { this.commentLines = commentLines; } public List<String> getValueLines() { return valueLines; } public void setValueLines(List<String> valueLines) { this.valueLines = valueLines; } public void clearValue() { this.valueLines = null; } } // class Layout }