From d3b6dfd61ea93e70eea4fd3f5d3b80f97e818db9 Mon Sep 17 00:00:00 2001 From: AryanRG Date: Sun, 18 Oct 2026 00:08:55 +0530 Subject: [PATCH] [TEXT-243] Skip substituted output in StringSubstitutorReader Consume skipped characters through the substituted reader so counts and positions reflect the output stream. Reject skip after this wrapper closes and cover expansion, contraction, EOF, and wrapped readers that cannot skip. Generated-by: OpenAI Codex --- .../text/io/StringSubstitutorReader.java | 43 ++++ .../io/StringSubstitutorReaderSkipTest.java | 184 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/test/java/org/apache/commons/text/io/StringSubstitutorReaderSkipTest.java diff --git a/src/main/java/org/apache/commons/text/io/StringSubstitutorReader.java b/src/main/java/org/apache/commons/text/io/StringSubstitutorReader.java index 1ba5add11a..ad2e9814ec 100644 --- a/src/main/java/org/apache/commons/text/io/StringSubstitutorReader.java +++ b/src/main/java/org/apache/commons/text/io/StringSubstitutorReader.java @@ -44,9 +44,15 @@ public class StringSubstitutorReader extends FilterReader { /** The end-of-stream character marker. */ private static final int EOS = -1; + /** Maximum size of the buffer used to skip substituted characters. */ + private static final int MAX_SKIP_BUFFER_SIZE = 2048; + /** Our internal buffer. */ private final TextStringBuilder buffer = new TextStringBuilder(); + /** Whether this reader has been closed. */ + private boolean closed; + /** End-of-Stream flag. */ private boolean eos; @@ -77,6 +83,15 @@ public StringSubstitutorReader(final Reader reader, final StringSubstitutor stri .andThen(stringSubstitutor.getVariablePrefixMatcher()); } + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + closed = true; + } + } + /** * Buffers the requested number of characters if available. */ @@ -312,4 +327,32 @@ private int readCount(final int count, final int pos) { return avail >= count ? 0 : count - avail; } + /** + * Skips characters in the substituted output. + * + * @param count The number of characters to skip. + * @return The number of characters actually skipped. + * @throws IllegalArgumentException if {@code count} is negative. + * @throws IOException If an I/O error occurs. + */ + @Override + public long skip(final long count) throws IOException { + if (count < 0) { + throw new IllegalArgumentException("Skip count must not be negative"); + } + if (closed) { + throw new IOException("Stream closed"); + } + final char[] skipBuffer = new char[(int) Math.min(count, MAX_SKIP_BUFFER_SIZE)]; + long remaining = count; + while (remaining > 0) { + final int readCount = read(skipBuffer, 0, (int) Math.min(remaining, skipBuffer.length)); + if (readCount == EOS) { + break; + } + remaining -= readCount; + } + return count - remaining; + } + } diff --git a/src/test/java/org/apache/commons/text/io/StringSubstitutorReaderSkipTest.java b/src/test/java/org/apache/commons/text/io/StringSubstitutorReaderSkipTest.java new file mode 100644 index 0000000000..2b6eb8135f --- /dev/null +++ b/src/test/java/org/apache/commons/text/io/StringSubstitutorReaderSkipTest.java @@ -0,0 +1,184 @@ +/* + * 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.commons.text.io; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.Collections; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringSubstitutor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests skipping characters in the substituted stream. + */ +class StringSubstitutorReaderSkipTest { + + private Reader createReader(final String template, final String value) { + return new StringSubstitutorReader(new StringReader(template), + new StringSubstitutor(Collections.singletonMap("v", value))); + } + + private Reader createReader(final Reader input) { + return new StringSubstitutorReader(input, + new StringSubstitutor(Collections.singletonMap("v", "abc"))); + } + + private Reader createNoSkipInput() { + return new Reader() { + private final StringReader input = new StringReader("${v}Z"); + + @Override + public int read(final char[] target, final int offset, final int length) throws IOException { + return input.read(target, offset, length); + } + + @Override + public long skip(final long count) throws IOException { + throw new IOException("skip unsupported"); + } + + @Override + public void close() throws IOException { + input.close(); + } + }; + } + + private Reader createInheritedSkipInput() { + return new Reader() { + private final StringReader input = new StringReader("${v}Z"); + + @Override + public int read(final char[] target, final int offset, final int length) throws IOException { + return input.read(target, offset, length); + } + + @Override + public void close() throws IOException { + input.close(); + } + }; + } + + @ParameterizedTest + @CsvSource({ + "${v}Z, abc, 1, 1, bcZ", + "A${v}Z, bc, 2, 2, cZ", + "${v}Z, abcdef, 5, 5, fZ", + "${v}Z, x, 5, 2, ''", + "${v}Z, '', 1, 1, ''", + "$${v}Z, abc, 2, 2, v}Z", + "plain, abc, 2, 2, ain" + }) + void testSkip(final String template, final String value, final long count, final long expectedCount, + final String remaining) throws IOException { + try (Reader reader = createReader(template, value)) { + assertEquals(expectedCount, reader.skip(count)); + assertEquals(remaining, IOUtils.toString(reader)); + assertEquals(0, reader.skip(1)); + } + } + + @Test + void testSkipAfterRead() throws IOException { + try (Reader reader = createReader("${v}Z", "abcd")) { + assertEquals('a', reader.read()); + assertEquals(2, reader.skip(2)); + assertEquals("dZ", IOUtils.toString(reader)); + } + } + + @Test + void testSkipDoesNotCallWrappedSkip() throws IOException { + try (Reader reader = createReader(createNoSkipInput())) { + assertEquals('a', reader.read()); + assertEquals(1, reader.skip(1)); + assertEquals("cZ", IOUtils.toString(reader)); + } + } + + @Test + void testSkipZeroDoesNotCallWrappedSkip() throws IOException { + try (Reader reader = createReader(createNoSkipInput())) { + assertEquals(0, reader.skip(0)); + assertEquals("abcZ", IOUtils.toString(reader)); + } + } + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void testSkipClosedWithInheritedSkip(final long count) throws IOException { + final Reader reader = createReader(createInheritedSkipInput()); + assertEquals("abcZ", IOUtils.toString(reader)); + reader.close(); + assertThrows(IOException.class, () -> reader.skip(count)); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1}) + void testSkipClosed(final long count) throws IOException { + final Reader reader = createReader("${v}Z", "abc"); + try { + assertEquals("abcZ", IOUtils.toString(reader)); + } finally { + reader.close(); + } + assertThrows(IOException.class, () -> reader.skip(count)); + } + + @Test + void testSkipLargeReplacement() throws IOException { + try (Reader reader = createReader("${v}Z", StringUtils.repeat('a', 4096))) { + assertEquals(4096, reader.skip(4096)); + assertEquals("Z", IOUtils.toString(reader)); + } + } + + @Test + void testSkipLongMaxValue() throws IOException { + try (Reader reader = createReader("${v}Z", "x")) { + assertEquals(2, reader.skip(Long.MAX_VALUE)); + assertEquals(-1, reader.read()); + } + } + + @Test + void testSkipNegative() throws IOException { + try (Reader reader = createReader("${v}Z", "abc")) { + assertThrows(IllegalArgumentException.class, () -> reader.skip(-1)); + assertEquals("abcZ", IOUtils.toString(reader)); + } + } + + @Test + void testSkipZero() throws IOException { + try (Reader reader = createReader("${v}Z", "abc")) { + assertEquals(0, reader.skip(0)); + assertEquals("abcZ", IOUtils.toString(reader)); + } + } +}