001/*
002  Licensed to the Apache Software Foundation (ASF) under one or more
003  contributor license agreements.  See the NOTICE file distributed with
004  this work for additional information regarding copyright ownership.
005  The ASF licenses this file to You under the Apache License, Version 2.0
006  (the "License"); you may not use this file except in compliance with
007  the License.  You may obtain a copy of the License at
008
009      https://www.apache.org/licenses/LICENSE-2.0
010
011  Unless required by applicable law or agreed to in writing, software
012  distributed under the License is distributed on an "AS IS" BASIS,
013  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014  See the License for the specific language governing permissions and
015  limitations under the License.
016 */
017package org.apache.commons.cli.help;
018
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.HashSet;
025import java.util.LinkedList;
026import java.util.List;
027import java.util.Queue;
028import java.util.Set;
029
030/**
031 * Writes text format output.
032 *
033 * @since 1.10.0
034 */
035public class TextHelpAppendable extends FilterHelpAppendable {
036
037    /** The default number of characters per line: {@value}. */
038    public static final int DEFAULT_WIDTH = 74;
039
040    /** The default padding to the left of each line: {@value}. */
041    public static final int DEFAULT_LEFT_PAD = 1;
042
043    /** The number of space characters to be prefixed to each description line: {@value}. */
044    public static final int DEFAULT_INDENT = 3;
045
046    /** The number of space characters before a list continuation line: {@value}. */
047    public static final int DEFAULT_LIST_INDENT = 7;
048
049    /** A blank line in the output: {@value}. */
050    private static final String BLANK_LINE = "";
051
052    /** The set of characters that are breaks in text. */
053    // @formatter:off
054    private static final Set<Character> BREAK_CHAR_SET = Collections.unmodifiableSet(new HashSet<>(Arrays.asList('\t', '\n', '\f', '\r',
055            (char) Character.LINE_SEPARATOR,
056            (char) Character.PARAGRAPH_SEPARATOR,
057            '\u000b', // VERTICAL TABULATION.
058            '\u001c', // FILE SEPARATOR.
059            '\u001d', // GROUP SEPARATOR.
060            '\u001e', // RECORD SEPARATOR.
061            '\u001f' // UNIT SEPARATOR.
062    )));
063    // @formatter:on
064
065    /**
066     * Finds the next text wrap position after {@code startPos} for the text in {@code text} with the column width {@code width}. The wrap point is the last
067     * position before startPos+width having a whitespace character (space, \n, \r). If there is no whitespace character before startPos+width, it will return
068     * startPos+width.
069     *
070     * @param text     The text being searched for the wrap position
071     * @param width    width of the wrapped text
072     * @param startPos position from which to start the lookup whitespace character
073     * @return position on which the text must be wrapped or @{code text.length()} if the wrap position is at the end of the text.
074     */
075    public static int indexOfWrap(final CharSequence text, final int width, final int startPos) {
076        if (width < 1) {
077            throw new IllegalArgumentException("Width must be greater than 0");
078        }
079        // handle case of width > text.
080        // the line ends before the max wrap pos or a new line char found
081        int limit = Math.min(startPos + width, text.length());
082        for (int idx = startPos; idx < limit; idx++) {
083            if (BREAK_CHAR_SET.contains(text.charAt(idx))) {
084                return idx;
085            }
086        }
087        if (startPos + width >= text.length()) {
088            return text.length();
089        }
090
091        limit = Math.min(startPos + width, text.length() - 1);
092        int pos;
093        // look for the last whitespace character before limit
094        for (pos = limit; pos >= startPos; --pos) {
095            if (Util.isWhitespace(text.charAt(pos))) {
096                break;
097            }
098        }
099        // if we found it return it, otherwise just chop at limit
100        return pos > startPos ? pos : limit - 1;
101    }
102
103    /**
104     * Creates a new TextHelpAppendable on {@link System#out}.
105     *
106     * @return a new TextHelpAppendable on {@link System#out}.
107     */
108    protected static TextHelpAppendable systemOut() {
109        return new TextHelpAppendable(System.out);
110    }
111
112    /** Defines the TextStyle for paragraph, and associated output formats. */
113    private final TextStyle.Builder textStyleBuilder;
114
115    /**
116     * Constructs an appendable filter built on top of the specified underlying appendable.
117     *
118     * @param output the underlying appendable to be assigned to the field {@code this.output} for later use, or {@code null} if this instance is to be created
119     *               without an underlying stream.
120     */
121    public TextHelpAppendable(final Appendable output) {
122        super(output);
123        // @formatter:off
124        textStyleBuilder = TextStyle.builder()
125            .setMaxWidth(DEFAULT_WIDTH)
126            .setLeftPad(DEFAULT_LEFT_PAD)
127            .setIndent(DEFAULT_INDENT);
128        // @formatter:on
129    }
130
131    /**
132     * Adjusts the table format.
133     * <p>
134     * Given the width of the page and the size of the table attempt to resize the columns to fit the page width if necessary. Adjustments are made as follows:
135     * </p>
136     * <ul>
137     * <li>The minimum size for a column may not be smaller than the length of the column header</li>
138     * <li>The maximum size is set to the maximum of the length of the header or the longest line length.</li>
139     * <li>If the total size of the columns is greater than the page wight, adjust the size of VARIABLE columns to attempt reduce the width to the the maximum
140     * size.
141     * </ul>
142     * <p>
143     * Note: it is possible for the size of the columns to exceed the declared page width. In this case the table will extend beyond the desired page width.
144     * </p>
145     *
146     * @param table the table to adjust.
147     * @return a new TableDefinition with adjusted values.
148     */
149    protected TableDefinition adjustTableFormat(final TableDefinition table) {
150        final List<TextStyle.Builder> styleBuilders = new ArrayList<>();
151        for (int i = 0; i < table.columnTextStyles().size(); i++) {
152            final TextStyle style = table.columnTextStyles().get(i);
153            final TextStyle.Builder builder = TextStyle.builder().setTextStyle(style);
154            styleBuilders.add(builder);
155            final String header = table.headers().get(i);
156
157            if (style.getMaxWidth() < header.length() || style.getMaxWidth() == TextStyle.UNSET_MAX_WIDTH) {
158                builder.setMaxWidth(header.length());
159            }
160            if (style.getMinWidth() < header.length()) {
161                builder.setMinWidth(header.length());
162            }
163            for (final List<String> row : table.rows()) {
164                final String cell = row.get(i);
165                if (cell.length() > builder.getMaxWidth()) {
166                    builder.setMaxWidth(cell.length());
167                }
168            }
169        }
170        // calculate the total width.
171        int calcWidth = 0;
172        int adjustedMaxWidth = textStyleBuilder.getMaxWidth();
173        for (final TextStyle.Builder builder : styleBuilders) {
174            adjustedMaxWidth -= builder.getLeftPad();
175            if (builder.isScalable()) {
176                calcWidth += builder.getMaxWidth();
177            } else {
178                adjustedMaxWidth -= builder.getMaxWidth();
179            }
180        }
181        // rescale if necessary
182        if (calcWidth > adjustedMaxWidth) {
183            final double fraction = adjustedMaxWidth * 1.0 / calcWidth;
184            for (int i = 0; i < styleBuilders.size(); i++) {
185                final TextStyle.Builder builder = styleBuilders.get(i);
186                if (builder.isScalable()) {
187                    // resize and remove the padding from the maxWidth calculation.
188                    styleBuilders.set(i, resize(builder, fraction));
189                }
190            }
191        }
192        // regenerate the styles
193        final List<TextStyle> styles = new ArrayList<>();
194        // adjust by removing the padding as it was not accounted for above.
195        styleBuilders.forEach(builder -> styles.add(builder.get()));
196        return TableDefinition.from(table.caption(), styles, table.headers(), table.rows());
197    }
198
199    @Override
200    public void appendHeader(final int level, final CharSequence text) throws IOException {
201        if (!Util.isEmpty(text)) {
202            if (level < 1) {
203                throw new IllegalArgumentException("level must be at least 1");
204            }
205            final char[] fillChars = { '=', '%', '+', '_' };
206            final int idx = Math.min(level, fillChars.length) - 1;
207            final TextStyle style = textStyleBuilder.get();
208            final Queue<String> queue = makeColumnQueue(text, style);
209            queue.add(Util.repeatSpace(style.getLeftPad()) + Util.repeat(Math.min(text.length(), style.getMaxWidth()), fillChars[idx]));
210            queue.add(BLANK_LINE);
211            printQueue(queue);
212        }
213    }
214
215    @Override
216    public void appendList(final boolean ordered, final Collection<CharSequence> list) throws IOException {
217        if (list != null && !list.isEmpty()) {
218            final TextStyle.Builder builder = TextStyle.builder().setLeftPad(textStyleBuilder.getLeftPad()).setIndent(DEFAULT_LIST_INDENT);
219            int i = 1;
220            for (final CharSequence line : list) {
221                final String entry = ordered ? String.format(" %s. %s", i++, Util.defaultValue(line, BLANK_LINE))
222                        : String.format(" * %s", Util.defaultValue(line, BLANK_LINE));
223                builder.setMaxWidth(Math.min(textStyleBuilder.getMaxWidth(), entry.length()));
224                printQueue(makeColumnQueue(entry, builder.get()));
225            }
226            output.append(System.lineSeparator());
227        }
228    }
229
230    @Override
231    public void appendParagraph(final CharSequence paragraph) throws IOException {
232        if (!Util.isEmpty(paragraph)) {
233            final Queue<String> queue = makeColumnQueue(paragraph, textStyleBuilder.get());
234            queue.add(BLANK_LINE);
235            printQueue(queue);
236        }
237    }
238
239    @Override
240    public void appendTable(final TableDefinition rawTable) throws IOException {
241        final TableDefinition table = adjustTableFormat(rawTable);
242        // write the table
243        appendParagraph(table.caption());
244        final List<TextStyle> headerStyles = new ArrayList<>();
245        table.columnTextStyles().forEach(style -> headerStyles.add(TextStyle.builder().setTextStyle(style).setAlignment(TextStyle.Alignment.CENTER).get()));
246        writeColumnQueues(makeColumnQueues(table.headers(), headerStyles), headerStyles);
247        for (final List<String> row : table.rows()) {
248            writeColumnQueues(makeColumnQueues(row, table.columnTextStyles()), table.columnTextStyles());
249        }
250        output.append(System.lineSeparator());
251    }
252
253    @Override
254    public void appendTitle(final CharSequence title) throws IOException {
255        if (!Util.isEmpty(title)) {
256            final TextStyle style = textStyleBuilder.get();
257            final Queue<String> queue = makeColumnQueue(title, style);
258            queue.add(Util.repeatSpace(style.getLeftPad()) + Util.repeat(Math.min(title.length(), style.getMaxWidth()), '#'));
259            queue.add(BLANK_LINE);
260            printQueue(queue);
261        }
262    }
263
264    /**
265     * Gets the indent for the output.
266     *
267     * @return the indent of the page.
268     */
269    public int getIndent() {
270        return textStyleBuilder.getIndent();
271    }
272
273    /**
274     * Returns the left padding for the output.
275     *
276     * @return The left padding for the output.
277     */
278    public int getLeftPad() {
279        return textStyleBuilder.getLeftPad();
280    }
281
282    /**
283     * Gets the maximum width for the output
284     *
285     * @return the maximum width for the output.
286     */
287    public int getMaxWidth() {
288        return textStyleBuilder.getMaxWidth();
289    }
290
291    /**
292     * Gets the style builder used to format text that is not otherwise formatted.
293     *
294     * @return The style builder used to format text that is not otherwise formatted.
295     */
296    public TextStyle.Builder getTextStyleBuilder() {
297        return textStyleBuilder;
298    }
299
300    /**
301     * Creates a queue comprising strings extracted from columnData where the alignment and length are determined by the style.
302     *
303     * @param columnData The string to wrap
304     * @param style      The TextStyle to guide the wrapping.
305     * @return A queue of the string wrapped.
306     */
307    protected Queue<String> makeColumnQueue(final CharSequence columnData, final TextStyle style) {
308        final String lpad = Util.repeatSpace(style.getLeftPad());
309        final String indent = Util.repeatSpace(style.getIndent());
310        final Queue<String> result = new LinkedList<>();
311        int wrapPos = 0;
312        int lastPos;
313        final int wrappedMaxWidth = style.getMaxWidth() - indent.length();
314        while (wrapPos < columnData.length()) {
315            final int workingWidth = wrapPos == 0 ? style.getMaxWidth() : wrappedMaxWidth;
316            lastPos = indexOfWrap(columnData, workingWidth, wrapPos);
317            final CharSequence working = columnData.subSequence(wrapPos, lastPos);
318            result.add(lpad + style.pad(wrapPos > 0, working));
319            wrapPos = Util.indexOfNonWhitespace(columnData, lastPos);
320            wrapPos = wrapPos == -1 ? lastPos + 1 : wrapPos;
321        }
322        return result;
323    }
324
325    /**
326     * For each column in the {@code columnData} apply the associated {@link TextStyle} and generated a queue of strings that are the maximum size of the column
327     * + the left pad.
328     *
329     * @param columnData The column data to output.
330     * @param styles     the styles to apply.
331     * @return A list of queues of strings that represent each column in the table.
332     */
333    protected List<Queue<String>> makeColumnQueues(final List<String> columnData, final List<TextStyle> styles) {
334        final List<Queue<String>> result = new ArrayList<>();
335        for (int i = 0; i < columnData.size(); i++) {
336            result.add(makeColumnQueue(columnData.get(i), styles.get(i)));
337        }
338        return result;
339    }
340
341    /**
342     * Prints a queue of text.
343     *
344     * @param queue the queue of text to print.
345     * @throws IOException on output error.
346     */
347    private void printQueue(final Queue<String> queue) throws IOException {
348        for (final String s : queue) {
349            appendFormat("%s%n", Util.rtrim(s));
350        }
351    }
352
353    /**
354     * Prints wrapped text using the TextHelpAppendable output style.
355     *
356     * @param text the text to wrap
357     * @throws IOException on output error.
358     */
359    public void printWrapped(final String text) throws IOException {
360        printQueue(makeColumnQueue(text, this.textStyleBuilder.get()));
361    }
362
363    /**
364     * Prints wrapped text.
365     *
366     * @param text  the text to wrap
367     * @param style the style for the wrapped text.
368     * @throws IOException on output error.
369     */
370    public void printWrapped(final String text, final TextStyle style) throws IOException {
371        printQueue(makeColumnQueue(text, style));
372    }
373
374    /**
375     * Resizes an original width based on the fractional size it should be.
376     *
377     * @param orig     the original size.
378     * @param fraction the fractional adjustment.
379     * @return the resized value.
380     */
381    private int resize(final int orig, final double fraction) {
382        return (int) (orig * fraction);
383    }
384
385    /**
386     * Resizes a TextStyle builder based on the fractional size.
387     *
388     * @param builder  the builder to adjust.
389     * @param fraction the fractional size (for example percentage of the current size) that the builder should be.
390     * @return the builder with the maximum width and indent values resized.
391     */
392    protected TextStyle.Builder resize(final TextStyle.Builder builder, final double fraction) {
393        final double indentFrac = builder.getIndent() * 1.0 / builder.getMaxWidth();
394        builder.setMaxWidth(Math.max(resize(builder.getMaxWidth(), fraction), builder.getMinWidth()));
395        final int maxAdjust = builder.getMaxWidth() / 3;
396        int newIndent = builder.getMaxWidth() == 1 ? 0 : builder.getIndent();
397        if (newIndent > maxAdjust) {
398            newIndent = Math.min(resize(builder.getIndent(), indentFrac), maxAdjust);
399        }
400        builder.setIndent(newIndent);
401        return builder;
402    }
403
404    /**
405     * Sets the indent for the output.
406     *
407     * @param indent the indent used for paragraphs.
408     */
409    public void setIndent(final int indent) {
410        textStyleBuilder.setIndent(indent);
411    }
412
413    /**
414     * Sets the left padding: the number of characters from the left edge to start output.
415     *
416     * @param leftPad the left padding.
417     */
418    public void setLeftPad(final int leftPad) {
419        textStyleBuilder.setLeftPad(leftPad);
420    }
421
422    /**
423     * Sets the maximum width for the output.
424     *
425     * @param maxWidth the maximum width for the output.
426     */
427    public void setMaxWidth(final int maxWidth) {
428        textStyleBuilder.setMaxWidth(maxWidth);
429    }
430
431    /**
432     * Writes one line from each of the {@code columnQueues} until all the queues are exhausted. If an exhausted queue is encountered while other queues
433     * continue to have content the exhausted queue will produce empty text for the output width of the column (maximum width + left pad).
434     *
435     * @param columnQueues the List of queues that represent the columns of data.
436     * @param styles       the TextStyle for each column.
437     * @throws IOException on output error.
438     */
439    protected void writeColumnQueues(final List<Queue<String>> columnQueues, final List<TextStyle> styles) throws IOException {
440        boolean moreData = true;
441        final String lPad = Util.repeatSpace(textStyleBuilder.get().getLeftPad());
442        while (moreData) {
443            output.append(lPad);
444            moreData = false;
445            for (int i = 0; i < columnQueues.size(); i++) {
446                final TextStyle style = styles.get(i);
447                final Queue<String> columnQueue = columnQueues.get(i);
448                final String line = columnQueue.poll();
449                if (Util.isEmpty(line)) {
450                    output.append(Util.repeatSpace(style.getMaxWidth() + style.getLeftPad()));
451                } else {
452                    output.append(line);
453                }
454                moreData |= !columnQueue.isEmpty();
455            }
456            output.append(System.lineSeparator());
457        }
458    }
459}