LineOrientedInterpolatingReader.java

package org.codehaus.plexus.util;

/*
 * Copyright The Codehaus Foundation.
 *
 * Licensed 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.
 */

import java.io.FilterReader;
import java.io.IOException;
import java.io.PushbackReader;
import java.io.Reader;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

import org.codehaus.plexus.util.reflection.Reflector;
import org.codehaus.plexus.util.reflection.ReflectorException;

/**
 * A FilterReader which interpolates keyword values into a character stream. Keywords are recognized when enclosed
 * between starting and ending delimiter strings. The keywords themselves, and their values, are fetched from a Map
 * supplied to the constructor.
 * <p>
 * When a possible keyword token is recognized (by detecting the starting and ending token delimiters):
 * </p>
 * <ul>
 * <li>if the enclosed string is found in the keyword Map, the delimiters and the keyword are effectively replaced by
 * the keyword's value;</li>
 * <li>if the enclosed string is found in the keyword Map, but its value has zero length, then the token (delimiters and
 * keyword) is effectively removed from the character stream;</li>
 * <li>if the enclosed string is <em>not</em> found in the keyword Map, then no substitution is made; the token text is
 * passed through unaltered.</li>
 * </ul>
 *
 * <p>A token in the incoming character stream may be <em>escaped</em> by prepending an "escape sequence" which is
 * specified to the constructor. An escaped token is passed through as written, with the escape sequence removed. This
 * allows things which would look like tokens to be read literally rather than interpolated.</p>
 *
 * @author jdcasey Created on Feb 3, 2005
 * @see InterpolationFilterReader
 */
public class LineOrientedInterpolatingReader extends FilterReader {
    public static final String DEFAULT_START_DELIM = "${";

    public static final String DEFAULT_END_DELIM = "}";

    public static final String DEFAULT_ESCAPE_SEQ = "\\";

    private static final char CARRIAGE_RETURN_CHAR = '\r';

    private static final char NEWLINE_CHAR = '\n';

    private final PushbackReader pushbackReader;

    private final Map<String, Object> context;

    private final String startDelim;

    private final String endDelim;

    private final String escapeSeq;

    private final int minExpressionSize;

    private final Reflector reflector;

    private int lineIdx = -1;

    private String line;

    /**
     * Construct an interpolating Reader, specifying token delimiters and the escape sequence.
     *
     * @param reader the Reader to be filtered.
     * @param context keyword/value pairs for interpolation.
     * @param startDelim character sequence which (possibly) begins a token.
     * @param endDelim character sequence which ends a token.
     * @param escapeSeq escape sequence
     */
    public LineOrientedInterpolatingReader(
            Reader reader, Map<String, ?> context, String startDelim, String endDelim, String escapeSeq) {
        super(reader);

        this.startDelim = startDelim;

        this.endDelim = endDelim;

        this.escapeSeq = escapeSeq;

        // Expressions have to be at least this size...
        this.minExpressionSize = startDelim.length() + endDelim.length() + 1;

        this.context = Collections.unmodifiableMap(context);

        this.reflector = new Reflector();

        if (reader instanceof PushbackReader) {
            this.pushbackReader = (PushbackReader) reader;
        } else {
            this.pushbackReader = new PushbackReader(reader, 1);
        }
    }

    /**
     * Filters a Reader using the default escape sequence "\".
     *
     * @param reader the Reader to be filtered.
     * @param context keyword/value pairs for interpolation.
     * @param startDelim the character sequence which (possibly) begins a token.
     * @param endDelim the character sequence which ends a token.
     */
    public LineOrientedInterpolatingReader(Reader reader, Map<String, ?> context, String startDelim, String endDelim) {
        this(reader, context, startDelim, endDelim, DEFAULT_ESCAPE_SEQ);
    }

    /**
     * Filters a Reader using the default escape sequence "\" and token delimiters "${", "}".
     *
     * @param reader the Reader to be filtered.
     * @param context keyword/value pairs for interpolation.
     */
    public LineOrientedInterpolatingReader(Reader reader, Map<String, ?> context) {
        this(reader, context, DEFAULT_START_DELIM, DEFAULT_END_DELIM, DEFAULT_ESCAPE_SEQ);
    }

    @Override
    public int read() throws IOException {
        if (line == null || lineIdx >= line.length()) {
            readAndInterpolateLine();
        }

        int next = -1;

        if (line != null && lineIdx < line.length()) {
            next = line.charAt(lineIdx++);
        }

        return next;
    }

    @Override
    public int read(char[] cbuf, int off, int len) throws IOException {
        int fillCount = 0;

        for (int i = off; i < off + len; i++) {
            int next = read();
            if (next > -1) {
                cbuf[i] = (char) next;
            } else {
                break;
            }

            fillCount++;
        }

        if (fillCount == 0) {
            fillCount = -1;
        }

        return fillCount;
    }

    @Override
    public long skip(long n) throws IOException {
        long skipCount = 0;

        for (long i = 0; i < n; i++) {
            int next = read();

            if (next < 0) {
                break;
            }

            skipCount++;
        }

        return skipCount;
    }

    private void readAndInterpolateLine() throws IOException {
        String rawLine = readLine();

        if (rawLine != null) {
            Set<String> expressions = parseForExpressions(rawLine);

            Map<String, Object> evaluatedExpressions = evaluateExpressions(expressions);

            String interpolated = replaceWithInterpolatedValues(rawLine, evaluatedExpressions);

            if (interpolated != null && interpolated.length() > 0) {
                line = interpolated;
                lineIdx = 0;
            }
        } else {
            line = null;
            lineIdx = -1;
        }
    }

    /*
     * Read one line from the wrapped Reader. A line is a sequence of characters ending in CRLF, CR, or LF. The
     * terminating character(s) will be included in the returned line.
     */
    private String readLine() throws IOException {
        StringBuilder lineBuffer = new StringBuilder(40); // half of the "normal" line maxsize
        int next;

        boolean lastWasCR = false;
        while ((next = pushbackReader.read()) > -1) {
            char c = (char) next;

            if (c == CARRIAGE_RETURN_CHAR) {
                lastWasCR = true;
                lineBuffer.append(c);
            } else if (c == NEWLINE_CHAR) {
                lineBuffer.append(c);
                break; // end of line.
            } else if (lastWasCR) {
                pushbackReader.unread(c);
                break;
            } else {
                lineBuffer.append(c);
            }
        }

        if (lineBuffer.length() < 1) {
            return null;
        } else {
            return lineBuffer.toString();
        }
    }

    private String replaceWithInterpolatedValues(String rawLine, Map<String, Object> evaluatedExpressions) {
        String result = rawLine;

        for (Object o : evaluatedExpressions.entrySet()) {
            Map.Entry entry = (Map.Entry) o;

            String expression = (String) entry.getKey();

            String value = String.valueOf(entry.getValue());

            result = findAndReplaceUnlessEscaped(result, expression, value);
        }

        return result;
    }

    private Map<String, Object> evaluateExpressions(Set<String> expressions) {
        Map<String, Object> evaluated = new TreeMap<String, Object>();

        for (Object expression : expressions) {
            String rawExpression = (String) expression;

            String realExpression =
                    rawExpression.substring(startDelim.length(), rawExpression.length() - endDelim.length());

            String[] parts = realExpression.split("\\.");
            if (parts.length > 0) {
                Object value = context.get(parts[0]);

                if (value != null) {
                    for (int i = 1; i < parts.length; i++) {
                        try {
                            value = reflector.getObjectProperty(value, parts[i]);

                            if (value == null) {
                                break;
                            }
                        } catch (ReflectorException e) {
                            // TODO: Fix this! It should report, but not interrupt.
                            e.printStackTrace();

                            break;
                        }
                    }

                    evaluated.put(rawExpression, value);
                }
            }
        }

        return evaluated;
    }

    private Set<String> parseForExpressions(String rawLine) {
        Set<String> expressions = new HashSet<String>();

        if (rawLine != null) {
            int placeholder = -1;

            do {
                // find the beginning of the next expression.
                int start = findDelimiter(rawLine, startDelim, placeholder);

                // if we can't find a start-delimiter, then there is no valid expression. Ignore everything else.
                if (start < 0) {
                    // no expression found.
                    break;
                }

                // find the end of the next expression.
                int end = findDelimiter(rawLine, endDelim, start + 1);

                // if we can't find an end-delimiter, then this is not a valid expression. Ignore it.
                if (end < 0) {
                    // no VALID expression found.
                    break;
                }

                // if we reach this point, we have a valid start and end position, which
                // means we have a valid expression. So, we add it to the set of
                // expressions in need of evaluation.
                expressions.add(rawLine.substring(start, end + endDelim.length()));

                // increment the placeholder so we can look beyond this expression.
                placeholder = end + 1;
            } while (placeholder < rawLine.length() - minExpressionSize);
        }

        return expressions;
    }

    private int findDelimiter(String rawLine, String delimiter, int lastPos) {
        int placeholder = lastPos;

        int position;
        do {
            position = rawLine.indexOf(delimiter, placeholder);

            if (position < 0) {
                break;
            } else {
                int escEndIdx = rawLine.indexOf(escapeSeq, placeholder) + escapeSeq.length();

                if (escEndIdx > escapeSeq.length() - 1 && escEndIdx == position) {
                    placeholder = position + 1;
                    position = -1;
                }
            }

        } while (position < 0 && placeholder < rawLine.length() - endDelim.length());
        // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        // use length() - endDelim.length() b/c otherwise there is nothing left to search.

        return position;
    }

    private String findAndReplaceUnlessEscaped(String rawLine, String search, String replace) {
        StringBuilder lineBuffer = new StringBuilder((int) (rawLine.length() * 1.5));

        int lastReplacement = -1;

        do {
            int nextReplacement = rawLine.indexOf(search, lastReplacement + 1);
            if (nextReplacement > -1) {
                if (lastReplacement < 0) {
                    lastReplacement = 0;
                }

                lineBuffer.append(rawLine, lastReplacement, nextReplacement);

                int escIdx = rawLine.indexOf(escapeSeq, lastReplacement + 1);
                if (escIdx > -1 && escIdx + escapeSeq.length() == nextReplacement) {
                    lineBuffer.setLength(lineBuffer.length() - escapeSeq.length());
                    lineBuffer.append(search);
                } else {
                    lineBuffer.append(replace);
                }

                lastReplacement = nextReplacement + search.length();
            } else {
                break;
            }
        } while (lastReplacement > -1);

        if (lastReplacement < rawLine.length()) {
            lineBuffer.append(rawLine, lastReplacement, rawLine.length());
        }

        return lineBuffer.toString();
    }
}