blob: fbdb6b930280c691949088f81e27809ca3e2d9d7 [file] [log] [blame]
/*
* 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
*
* https://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.charset.StandardCharsets;
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="https://docs.oracle.com/javase/8/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 eol = System.lineSeparator();
/**
* 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(StandardCharsets.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);
p.setValue(value);
} else {
key = escapeName(key);
final Pair p = new Pair(key, value);
p.setNew(true);
keyedPairLines.put(key, 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);
}
logicalLines.set(i, null);
}
return obj;
}
@Override
public Object 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, (Pair) 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
* @throws IOException if save fails
*/
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, StandardCharsets.ISO_8859_1);
int skipLines = 0;
final int totalLines = logicalLines.size();
if (header != null) {
osw.write("#" + header + eol);
if (totalLines > 0
&& logicalLines.get(0) instanceof Comment
&& header.equals(logicalLines.get(0).toString().substring(1))) {
skipLines = 1;
}
}
// we may be updating 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() + eol);
boolean writtenSep = false;
for (LogicalLine line : logicalLines.subList(skipLines, totalLines)) {
if (line instanceof Pair) {
if (((Pair) line).isNew()) {
if (!writtenSep) {
osw.write(eol);
writtenSep = true;
}
}
osw.write(line.toString() + eol);
} else if (line != null) {
osw.write(line.toString() + eol);
}
}
osw.close();
}
/**
* Reads a properties file into an internally maintained
* collection of logical lines (possibly spanning physical 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, StandardCharsets.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(eol);
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().isEmpty()) {
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, 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
eol = System.lineSeparator();
while (ch >= 0) {
if (hasCR && ch != '\n') {
// line feed is sole CR
r.unread(ch);
break;
}
if (ch == '\r') {
eol = "\r";
hasCR = true;
} else if (ch == '\n') {
eol = 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 Properties
* file, as laid out in the docs for <a
* href="https://docs.oracle.com/javase/8/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 carriage 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="https://docs.oracle.com/javase/8/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="https://docs.oracle.com/javase/8/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="https://docs.oracle.com/javase/8/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 (final char c : ch) {
if (c == ' ') {
if (escapeAllSpaces || leadingSpace) {
buffy.append("\\");
}
} else {
leadingSpace = false;
}
final int p = forEscaping.indexOf(c);
if (p != -1) {
buffy.append("\\").append(escaped, 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 constitues 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 Object clone() {
Object dolly = null;
try {
dolly = 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));
}
// 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;
}
}
}