StringSubstitutorReader.java
/*
* 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.commons.text.io;
import java.io.FilterReader;
import java.io.IOException;
import java.io.Reader;
import org.apache.commons.text.StringSubstitutor;
import org.apache.commons.text.TextStringBuilder;
import org.apache.commons.text.matcher.StringMatcher;
import org.apache.commons.text.matcher.StringMatcherFactory;
/**
* A {@link Reader} that performs string substitution on a source {@code Reader} using a {@link StringSubstitutor}.
*
* <p>
* Using this Reader avoids reading a whole file into memory as a {@code String} to perform string substitution, for
* example, when a Servlet filters a file to a client.
* </p>
* <p>
* This class is not thread-safe.
* </p>
*
* @since 1.9
*/
public class StringSubstitutorReader extends FilterReader {
/** The end-of-stream character marker. */
private static final int EOS = -1;
/** Our internal buffer. */
private final TextStringBuilder buffer = new TextStringBuilder();
/** End-of-Stream flag. */
private boolean eos;
/** Matches escaped variable starts. */
private final StringMatcher prefixEscapeMatcher;
/** Internal buffer for {@link #read()} method. */
private final char[] read1CharBuffer = {0};
/** The underlying StringSubstitutor. */
private final StringSubstitutor stringSubstitutor;
/** We don't always want to drain the whole buffer. */
private int toDrain;
/**
* Constructs a new instance.
*
* @param reader the underlying reader containing the template text known to the given {@code StringSubstitutor}.
* @param stringSubstitutor How to replace as we read.
* @throws NullPointerException if {@code reader} is {@code null}.
* @throws NullPointerException if {@code stringSubstitutor} is {@code null}.
*/
public StringSubstitutorReader(final Reader reader, final StringSubstitutor stringSubstitutor) {
super(reader);
this.stringSubstitutor = new StringSubstitutor(stringSubstitutor);
this.prefixEscapeMatcher = StringMatcherFactory.INSTANCE.charMatcher(stringSubstitutor.getEscapeChar())
.andThen(stringSubstitutor.getVariablePrefixMatcher());
}
/**
* Buffers the requested number of characters if available.
*/
private int buffer(final int requestReadCount) throws IOException {
final int actualReadCount = buffer.readFrom(super.in, requestReadCount);
eos = actualReadCount == EOS;
return actualReadCount;
}
/**
* Reads a requested number of chars from the underlying reader into the buffer. On EOS, set the state is DRAINING,
* drain, and return a drain count, otherwise, returns the actual read count.
*/
private int bufferOrDrainOnEos(final int requestReadCount, final char[] target, final int targetIndex,
final int targetLength) throws IOException {
final int actualReadCount = buffer(requestReadCount);
return drainOnEos(actualReadCount, target, targetIndex, targetLength);
}
/**
* Drains characters from our buffer to the given {@code target}.
*/
private int drain(final char[] target, final int targetIndex, final int targetLength) {
final int actualLen = Math.min(buffer.length(), targetLength);
final int drainCount = buffer.drainChars(0, actualLen, target, targetIndex);
toDrain -= drainCount;
if (buffer.isEmpty() || toDrain == 0) {
// nothing or everything drained.
toDrain = 0;
}
return drainCount;
}
/**
* Drains from the buffer to the target only if we are at EOS per the input count. If input count is EOS, drain and
* returns the drain count, otherwise return the input count. If draining, the state is set to DRAINING.
*/
private int drainOnEos(final int readCountOrEos, final char[] target, final int targetIndex,
final int targetLength) {
if (readCountOrEos == EOS) {
// At EOS, drain.
if (buffer.isNotEmpty()) {
toDrain = buffer.size();
return drain(target, targetIndex, targetLength);
}
return EOS;
}
return readCountOrEos;
}
/**
* Tests if our buffer matches the given string matcher at the given position in the buffer.
*/
private boolean isBufferMatchAt(final StringMatcher stringMatcher, final int pos) {
return stringMatcher.isMatch(buffer, pos) == stringMatcher.size();
}
/**
* Tests if we are draining.
*/
private boolean isDraining() {
return toDrain > 0;
}
/**
* Reads a single character.
*
* @return a character as an {@code int} or {@code -1} for end-of-stream.
* @throws IOException If an I/O error occurs
*/
@Override
public int read() throws IOException {
int count = 0;
// ask until we get a char or EOS
do {
count = read(read1CharBuffer, 0, 1);
if (count == EOS) {
return EOS;
}
// keep on buffering
} while (count < 1);
return read1CharBuffer[0];
}
/**
* Reads characters into a portion of an array.
*
* @param target Target buffer.
* @param targetIndexIn Index in the target at which to start storing characters.
* @param targetLengthIn Maximum number of characters to read.
*
* @return The number of characters read, or -1 on end of stream.
* @throws IOException If an I/O error occurs
*/
@Override
public int read(final char[] target, final int targetIndexIn, final int targetLengthIn) throws IOException {
// The whole thing is inefficient because we must look for a balanced suffix to match the starting prefix
// Trying to substitute an incomplete expression can perform replacements when it should not.
// At a high level:
// - if draining, drain until empty or target length hit
// - copy to target until we find a variable start
// - buffer until a balanced suffix is read, then substitute.
if (eos && buffer.isEmpty()) {
return EOS;
}
if (targetLengthIn <= 0) {
// short-circuit: ask nothing, give nothing
return 0;
}
// drain check
int targetIndex = targetIndexIn;
int targetLength = targetLengthIn;
if (isDraining()) {
// drain as much as possible
final int drainCount = drain(target, targetIndex, Math.min(toDrain, targetLength));
if (drainCount == targetLength) {
// drained length requested, target is full, can only do more in the next invocation
return targetLength;
}
// drained less than requested, target not full.
targetIndex += drainCount;
targetLength -= drainCount;
}
// BUFFER from the underlying reader
final int minReadLenPrefix = prefixEscapeMatcher.size();
// READ enough to test for an [optionally escaped] variable start
int readCount = buffer(readCount(minReadLenPrefix, 0));
if (buffer.length() < minReadLenPrefix && targetLength < minReadLenPrefix) {
// read less than minReadLenPrefix, no variable possible
final int drainCount = drain(target, targetIndex, targetLength);
targetIndex += drainCount;
final int targetSize = targetIndex - targetIndexIn;
return eos && targetSize <= 0 ? EOS : targetSize;
}
if (eos) {
// EOS
stringSubstitutor.replaceIn(buffer);
toDrain = buffer.size();
final int drainCount = drain(target, targetIndex, targetLength);
targetIndex += drainCount;
final int targetSize = targetIndex - targetIndexIn;
return eos && targetSize <= 0 ? EOS : targetSize;
}
// PREFIX
// buffer and drain until we find a variable start, escaped or plain.
int balance = 0;
final StringMatcher prefixMatcher = stringSubstitutor.getVariablePrefixMatcher();
int pos = 0;
while (targetLength > 0) {
if (isBufferMatchAt(prefixMatcher, 0)) {
balance = 1;
pos = prefixMatcher.size();
break;
}
if (isBufferMatchAt(prefixEscapeMatcher, 0)) {
balance = 1;
pos = prefixEscapeMatcher.size();
break;
}
// drain first char
final int drainCount = drain(target, targetIndex, 1);
targetIndex += drainCount;
targetLength -= drainCount;
if (buffer.size() < minReadLenPrefix) {
readCount = bufferOrDrainOnEos(minReadLenPrefix, target, targetIndex, targetLength);
if (eos || isDraining()) {
// if draining, readCount is a drain count
if (readCount != EOS) {
targetIndex += readCount;
targetLength -= readCount;
}
final int actual = targetIndex - targetIndexIn;
return actual > 0 ? actual : EOS;
}
}
}
// we found a variable start
if (targetLength <= 0) {
// no more room in target
return targetLengthIn;
}
// SUFFIX
// buffer more to find a balanced suffix
final StringMatcher suffixMatcher = stringSubstitutor.getVariableSuffixMatcher();
final int minReadLenSuffix = Math.max(minReadLenPrefix, suffixMatcher.size());
readCount = buffer(readCount(minReadLenSuffix, pos));
if (eos) {
// EOS
stringSubstitutor.replaceIn(buffer);
toDrain = buffer.size();
final int drainCount = drain(target, targetIndex, targetLength);
return targetIndex + drainCount - targetIndexIn;
}
// buffer and break out when we find the end or a balanced suffix
while (true) {
if (isBufferMatchAt(suffixMatcher, pos)) {
balance--;
pos++;
if (balance == 0) {
break;
}
} else if (isBufferMatchAt(prefixMatcher, pos)) {
balance++;
pos += prefixMatcher.size();
} else if (isBufferMatchAt(prefixEscapeMatcher, pos)) {
balance++;
pos += prefixEscapeMatcher.size();
} else {
pos++;
}
readCount = buffer(readCount(minReadLenSuffix, pos));
if (readCount == EOS && pos >= buffer.size()) {
break;
}
}
// substitute
final int endPos = pos + 1;
final int leftover = Math.max(0, buffer.size() - pos);
stringSubstitutor.replaceIn(buffer, 0, Math.min(buffer.size(), endPos));
pos = buffer.size() - leftover;
final int drainLen = Math.min(targetLength, pos);
// only drain up to what we've substituted
toDrain = pos;
drain(target, targetIndex, drainLen);
return targetIndex - targetIndexIn + drainLen;
}
/**
* Returns how many chars to attempt reading to have room in the buffer for {@code count} chars starting at position
* {@code pos}.
*/
private int readCount(final int count, final int pos) {
final int avail = buffer.size() - pos;
return avail >= count ? 0 : count - avail;
}
}