/* * 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.tools.ant.util; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintStream; import java.io.PushbackReader; import java.io.Serializable; import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; /** * <p>A Properties collection which preserves comments and whitespace * present in the input stream from which it was loaded.</p> * <p>The class defers the usual work of the <a href="http://java.sun.com/j2se/1.3/docs/api/java/util/Properties.html">java.util.Properties</a> * class to there, but it also keeps track of the contents of the * input stream from which it was loaded (if applicable), so that it can * write out the properties in as close a form as possible to the input.</p> * <p>If no changes occur to property values, the output should be the same * as the input, except for the leading date stamp, as normal for a * properties file. Properties added are appended to the file. Properties * whose values are changed are changed in place. Properties that are * removed are excised. If the <code>removeComments</code> flag is set, * then the comments immediately preceding the property are also removed.</p> * <p>If a second set of properties is loaded into an existing set, the * lines of the second set are added to the end. Note however, that if a * property already stored is present in a stream subsequently loaded, then * that property is removed before the new value is set. For example, * consider the file</p> * <pre> # the first line * alpha=one * * # the second line * beta=two</pre> * <p>This file is loaded, and then the following is also loaded into the * same <code>LayoutPreservingProperties</code> object</p> * <pre> # association * beta=band * * # and finally * gamma=rays</pre> * <p>The resulting collection sequence of logical lines depends on whether * or not <code>removeComments</code> was set at the time the second stream * is loaded. If it is set, then the resulting list of lines is</p> * <pre> # the first line * alpha=one * * # association * beta=band * * # and finally * gamma=rays</pre> * <p>If the flag is not set, then the comment "the second line" is retained, * although the key-value pair <code>beta=two</code> is removed.</p> */ public class LayoutPreservingProperties extends Properties { private static final long serialVersionUID = 1L; private String LS = StringUtils.LINE_SEP; /** * Logical lines have escaping and line continuation taken care * of. Comments and blank lines are logical lines; they are not * removed. */ private List<LogicalLine> logicalLines = new ArrayList<>(); /** * Position in the <code>logicalLines</code> list, keyed by property name. */ private Map<String, Integer> keyedPairLines = new HashMap<>(); /** * Flag to indicate that, when we remove a property from the file, we * also want to remove the comments that precede it. */ private boolean removeComments; /** * Create a new, empty, Properties collection, with no defaults. */ public LayoutPreservingProperties() { super(); } /** * Create a new, empty, Properties collection, with the specified defaults. * @param defaults the default property values */ public LayoutPreservingProperties(final Properties defaults) { super(defaults); } /** * Returns <code>true</code> if comments are removed along with * properties, or <code>false</code> otherwise. If * <code>true</code>, then when a property is removed, the comment * preceding it in the original file is removed also. * @return <code>true</code> if leading comments are removed when * a property is removed; <code>false</code> otherwise */ public boolean isRemoveComments() { return removeComments; } /** * Sets the behaviour for comments accompanying properties that * are being removed. If <code>true</code>, then when a property * is removed, the comment preceding it in the original file is * removed also. * @param val <code>true</code> if leading comments are to be * removed when a property is removed; <code>false</code> * otherwise */ public void setRemoveComments(final boolean val) { removeComments = val; } @Override public void load(final InputStream inStream) throws IOException { final String s = readLines(inStream); final byte[] ba = s.getBytes(ResourceUtils.ISO_8859_1); final ByteArrayInputStream bais = new ByteArrayInputStream(ba); super.load(bais); } @Override public Object put(final Object key, final Object value) throws NullPointerException { final Object obj = super.put(key, value); // the above call will have failed if key or value are null innerSetProperty(key.toString(), value.toString()); return obj; } @Override public Object setProperty(final String key, final String value) throws NullPointerException { final Object obj = super.setProperty(key, value); // the above call will have failed if key or value are null innerSetProperty(key, value); return obj; } /** * Store a new key-value pair, or add a new one. The normal * functionality is taken care of by the superclass in the call to * {@link #setProperty}; this method takes care of this classes * extensions. * @param key the key of the property to be stored * @param value the value to be stored */ private void innerSetProperty(String key, String value) { value = escapeValue(value); if (keyedPairLines.containsKey(key)) { final Integer i = keyedPairLines.get(key); final Pair p = (Pair) logicalLines.get(i.intValue()); p.setValue(value); } else { key = escapeName(key); final Pair p = new Pair(key, value); p.setNew(true); keyedPairLines.put(key, Integer.valueOf(logicalLines.size())); logicalLines.add(p); } } @Override public void clear() { super.clear(); keyedPairLines.clear(); logicalLines.clear(); } @Override public Object remove(final Object key) { final Object obj = super.remove(key); final Integer i = keyedPairLines.remove(key); if (null != i) { if (removeComments) { removeCommentsEndingAt(i.intValue()); } logicalLines.set(i.intValue(), null); } return obj; } @Override public LayoutPreservingProperties clone() { final LayoutPreservingProperties dolly = (LayoutPreservingProperties) super.clone(); dolly.keyedPairLines = new HashMap<>(this.keyedPairLines); dolly.logicalLines = new ArrayList<>(this.logicalLines); final int size = dolly.logicalLines.size(); for (int j = 0; j < size; j++) { final LogicalLine line = dolly.logicalLines.get(j); if (line instanceof Pair) { final Pair p = (Pair) line; dolly.logicalLines.set(j, p.clone()); } // no reason to clone other lines are they are immutable } return dolly; } /** * Echo the lines of the properties (including blanks and comments) to the * stream. * @param out the stream to write to */ public void listLines(final PrintStream out) { out.println("-- logical lines --"); for (LogicalLine line : logicalLines) { if (line instanceof Blank) { out.println("blank: \"" + line + "\""); } else if (line instanceof Comment) { out.println("comment: \"" + line + "\""); } else if (line instanceof Pair) { out.println("pair: \"" + line + "\""); } } } /** * Save the properties to a file. * @param dest the file to write to */ public void saveAs(final File dest) throws IOException { final OutputStream fos = Files.newOutputStream(dest.toPath()); store(fos, null); fos.close(); } @Override public void store(final OutputStream out, final String header) throws IOException { final OutputStreamWriter osw = new OutputStreamWriter(out, ResourceUtils.ISO_8859_1); int skipLines = 0; final int totalLines = logicalLines.size(); if (header != null) { osw.write("#" + header + LS); if (totalLines > 0 && logicalLines.get(0) instanceof Comment && header.equals(logicalLines.get(0).toString().substring(1))) { skipLines = 1; } } // we may be updatiung a file written by this class, replace // the date comment instead of adding a new one and preserving // the one written last time if (totalLines > skipLines && logicalLines.get(skipLines) instanceof Comment) { try { DateUtils.parseDateFromHeader(logicalLines .get(skipLines) .toString().substring(1)); skipLines++; } catch (final java.text.ParseException pe) { // not an existing date comment } } osw.write("#" + DateUtils.getDateForHeader() + LS); boolean writtenSep = false; for (LogicalLine line : logicalLines.subList(skipLines, totalLines)) { if (line instanceof Pair) { if (((Pair) line).isNew()) { if (!writtenSep) { osw.write(LS); writtenSep = true; } } osw.write(line.toString() + LS); } else if (line != null) { osw.write(line.toString() + LS); } } osw.close(); } /** * Reads a properties file into an internally maintained * collection of logical lines (possibly spanning physcial lines), * which make up the comments, blank lines and properties of the * file. * @param is the stream from which to read the data */ private String readLines(final InputStream is) throws IOException { final InputStreamReader isr = new InputStreamReader(is, ResourceUtils.ISO_8859_1); final PushbackReader pbr = new PushbackReader(isr, 1); if (!logicalLines.isEmpty()) { // we add a blank line for spacing logicalLines.add(new Blank()); } String s = readFirstLine(pbr); final BufferedReader br = new BufferedReader(pbr); boolean continuation = false; boolean comment = false; final StringBuilder fileBuffer = new StringBuilder(); final StringBuilder logicalLineBuffer = new StringBuilder(); while (s != null) { fileBuffer.append(s).append(LS); if (continuation) { // put in the line feed that was removed s = "\n" + s; } else { // could be a comment, if first non-whitespace is a # or ! comment = s.matches("^( |\t|\f)*(#|!).*"); } // continuation if not a comment and the line ends is an // odd number of backslashes if (!comment) { continuation = requiresContinuation(s); } logicalLineBuffer.append(s); if (!continuation) { LogicalLine line; if (comment) { line = new Comment(logicalLineBuffer.toString()); } else if (logicalLineBuffer.toString().trim().length() == 0) { line = new Blank(); } else { line = new Pair(logicalLineBuffer.toString()); final String key = unescape(((Pair)line).getName()); if (keyedPairLines.containsKey(key)) { // this key is already present, so we remove it and add // the new one remove(key); } keyedPairLines.put(key, new Integer(logicalLines.size())); } logicalLines.add(line); logicalLineBuffer.setLength(0); } s = br.readLine(); } return fileBuffer.toString(); } /** * Reads the first line and determines the EOL-style of the file * (relies on the style to be consistent, of course). * * <p>Sets LS as a side-effect.</p> * * @return the first line without any line separator, leaves the * reader positioned after the first line separator * * @since Ant 1.8.2 */ private String readFirstLine(final PushbackReader r) throws IOException { final StringBuilder sb = new StringBuilder(80); int ch = r.read(); boolean hasCR = false; // when reaching EOF before the first EOL, assume native line // feeds LS = StringUtils.LINE_SEP; while (ch >= 0) { if (hasCR && ch != '\n') { // line feed is sole CR r.unread(ch); break; } if (ch == '\r') { LS = "\r"; hasCR = true; } else if (ch == '\n') { LS = hasCR ? "\r\n" : "\n"; break; } else { sb.append((char) ch); } ch = r.read(); } return sb.toString(); } /** * Returns <code>true</code> if the line represented by * <code>s</code> is to be continued on the next line of the file, * or <code>false</code> otherwise. * @param s the contents of the line to examine * @return <code>true</code> if the line is to be continued, * <code>false</code> otherwise */ private boolean requiresContinuation(final String s) { final char[] ca = s.toCharArray(); int i = ca.length - 1; while (i > 0 && ca[i] == '\\') { i--; } // trailing backslashes final int tb = ca.length - i - 1; return tb % 2 == 1; } /** * Unescape the string according to the rules for a Properites * file, as laid out in the docs for <a * href="http://java.sun.com/j2se/1.3/docs/api/java/util/Properties.html">java.util.Properties</a>. * @param s the string to unescape (coming from the source file) * @return the unescaped string */ private String unescape(final String s) { /* * The following combinations are converted: * \n newline * \r carraige return * \f form feed * \t tab * \\ backslash * \u0000 unicode character * Any other slash is ignored, so * \b becomes 'b'. */ final char[] ch = new char[s.length() + 1]; s.getChars(0, s.length(), ch, 0); ch[s.length()] = '\n'; final StringBuilder buffy = new StringBuilder(s.length()); for (int i = 0; i < ch.length; i++) { char c = ch[i]; if (c == '\n') { // we have hit out end-of-string marker break; } if (c == '\\') { // possibly an escape sequence c = ch[++i]; if (c == 'n') { buffy.append('\n'); } else if (c == 'r') { buffy.append('\r'); } else if (c == 'f') { buffy.append('\f'); } else if (c == 't') { buffy.append('\t'); } else if (c == 'u') { // handle unicode escapes c = unescapeUnicode(ch, i+1); i += 4; buffy.append(c); } else { buffy.append(c); } } else { buffy.append(c); } } return buffy.toString(); } /** * Retrieve the unicode character whose code is listed at position * <code>i</code> in the character array <code>ch</code>. * @param ch the character array containing the unicode character code * @return the character extracted */ private char unescapeUnicode(final char[] ch, final int i) { final String s = new String(ch, i, 4); return (char) Integer.parseInt(s, 16); } /** * Escape the string <code>s</code> according to the rules in the * docs for <a * href="http://java.sun.com/j2se/1.3/docs/api/java/util/Properties.html">java.util.Properties</a>. * @param s the string to escape * @return the escaped string */ private String escapeValue(final String s) { return escape(s, false); } /** * Escape the string <code>s</code> according to the rules in the * docs for <a * href="http://java.sun.com/j2se/1.3/docs/api/java/util/Properties.html">java.util.Properties</a>. * This method escapes all the whitespace, not just the stuff at * the beginning. * @param s the string to escape * @return the escaped string */ private String escapeName(final String s) { return escape(s, true); } /** * Escape the string <code>s</code> according to the rules in the * docs for <a * href="http://java.sun.com/j2se/1.3/docs/api/java/util/Properties.html">java.util.Properties</a>. * @param s the string to escape * @param escapeAllSpaces if <code>true</code> the method escapes * all the spaces, if <code>false</code>, it escapes only the * leading whitespace * @return the escaped string */ private String escape(final String s, final boolean escapeAllSpaces) { if (s == null) { return null; } final char[] ch = new char[s.length()]; s.getChars(0, s.length(), ch, 0); final String forEscaping = "\t\f\r\n\\:=#!"; final String escaped = "tfrn\\:=#!"; final StringBuilder buffy = new StringBuilder(s.length()); boolean leadingSpace = true; for (int i = 0; i < ch.length; i++) { final char c = ch[i]; if (c == ' ') { if (escapeAllSpaces || leadingSpace) { buffy.append("\\"); } } else { leadingSpace = false; } final int p = forEscaping.indexOf(c); if (p != -1) { buffy.append("\\").append(escaped.substring(p,p+1)); } else if (c < 0x0020 || c > 0x007e) { buffy.append(escapeUnicode(c)); } else { buffy.append(c); } } return buffy.toString(); } /** * Return the unicode escape sequence for a character, in the form * \u005CuNNNN. * @param ch the character to encode * @return the unicode escape sequence */ private String escapeUnicode(final char ch) { return "\\" + UnicodeUtil.EscapeUnicode(ch); } /** * Remove the comments in the leading up the {@link logicalLines} * list leading up to line <code>pos</code>. * @param pos the line number to which the comments lead */ private void removeCommentsEndingAt(int pos) { /* We want to remove comments preceding this position. Step * back counting blank lines (call this range B1) until we hit * something non-blank. If what we hit is not a comment, then * exit. If what we hit is a comment, then step back counting * comment lines (call this range C1). Nullify lines in C1 and * B1. */ final int end = pos - 1; // step pos back until it hits something non-blank for (pos = end; pos > 0; pos--) { if (!(logicalLines.get(pos) instanceof Blank)) { break; } } // if the thing it hits is not a comment, then we have nothing // to remove if (!(logicalLines.get(pos) instanceof Comment)) { return; } // step back until we hit the start of the comment for (; pos >= 0; pos--) { if (!(logicalLines.get(pos) instanceof Comment)) { break; } } // now we want to delete from pos+1 to end for (pos++; pos <= end; pos++) { logicalLines.set(pos, null); } } /** * A logical line of the properties input stream. */ private abstract static class LogicalLine implements Serializable { private static final long serialVersionUID = 1L; private String text; public LogicalLine(final String text) { this.text = text; } public void setText(final String text) { this.text = text; } @Override public String toString() { return text; } } /** * A blank line of the input stream. */ private static class Blank extends LogicalLine { private static final long serialVersionUID = 1L; public Blank() { super(""); } } /** * A comment line of the input stream. */ private class Comment extends LogicalLine { private static final long serialVersionUID = 1L; public Comment(final String text) { super(text); } } /** * A key-value pair from the input stream. This may span more than * one physical line, but it is constitutes as a single logical * line. */ private static class Pair extends LogicalLine implements Cloneable { private static final long serialVersionUID = 1L; private String name; private String value; private boolean added; public Pair(final String text) { super(text); parsePair(text); } public Pair(final String name, final String value) { this(name + "=" + value); } public String getName() { return name; } @SuppressWarnings("unused") public String getValue() { return value; } public void setValue(final String value) { this.value = value; setText(name + "=" + value); } public boolean isNew() { return added; } public void setNew(final boolean val) { added = val; } @Override public Pair clone() { Pair dolly = null; try { dolly = (Pair) super.clone(); } catch (final CloneNotSupportedException e) { // should be fine e.printStackTrace(); //NOSONAR } return dolly; } private void parsePair(final String text) { // need to find first non-escaped '=', ':', '\t' or ' '. final int pos = findFirstSeparator(text); if (pos == -1) { // trim leading whitespace only name = text; setValue(null); } else { name = text.substring(0, pos); setValue(text.substring(pos+1, text.length())); } // trim leading whitespace only name = stripStart(name, " \t\f"); } private String stripStart(final String s, final String chars) { if (s == null) { return null; } int i = 0; for (;i < s.length(); i++) { if (chars.indexOf(s.charAt(i)) == -1) { break; } } if (i == s.length()) { return ""; } return s.substring(i); } private int findFirstSeparator(String s) { // Replace double backslashes with underscores so that they don't // confuse us looking for '\t' or '\=', for example, but they also // don't change the position of other characters s = s.replaceAll("\\\\\\\\", "__"); // Replace single backslashes followed by separators, so we don't // pick them up s = s.replaceAll("\\\\=", "__"); s = s.replaceAll("\\\\:", "__"); s = s.replaceAll("\\\\ ", "__"); s = s.replaceAll("\\\\t", "__"); // Now only the unescaped separators are left return indexOfAny(s, " :=\t"); } private int indexOfAny(final String s, final String chars) { if (s == null || chars == null) { return -1; } int p = s.length() + 1; for (int i = 0; i < chars.length(); i++) { final int x = s.indexOf(chars.charAt(i)); if (x != -1 && x < p) { p = x; } } if (p == s.length() + 1) { return -1; } return p; } } }