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        final int limit = Math.min(startPos + width, text.length() - 1);
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        int pos;
091        // look for the last whitespace character before limit
092        for (pos = limit; pos >= startPos; --pos) {
093            if (Util.isWhitespace(text.charAt(pos))) {
094                break;
095            }
096        }
097        // if we found it return it, otherwise just chop at limit
098        return pos > startPos ? pos : limit - 1;
099    }
100
101    /**
102     * Creates a new TextHelpAppendable on {@link System#out}.
103     *
104     * @return a new TextHelpAppendable on {@link System#out}.
105     */
106    protected static TextHelpAppendable systemOut() {
107        return new TextHelpAppendable(System.out);
108    }
109
110    /** Defines the TextStyle for paragraph, and associated output formats. */
111    private final TextStyle.Builder textStyleBuilder;
112
113    /**
114     * Constructs an appendable filter built on top of the specified underlying appendable.
115     *
116     * @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
117     *               without an underlying stream.
118     */
119    public TextHelpAppendable(final Appendable output) {
120        super(output);
121        // @formatter:off
122        textStyleBuilder = TextStyle.builder()
123            .setMaxWidth(DEFAULT_WIDTH)
124            .setLeftPad(DEFAULT_LEFT_PAD)
125            .setIndent(DEFAULT_INDENT);
126        // @formatter:on
127    }
128
129    /**
130     * Adjusts the table format.
131     * <p>
132     * 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:
133     * </p>
134     * <ul>
135     * <li>The minimum size for a column may not be smaller than the length of the column header</li>
136     * <li>The maximum size is set to the maximum of the length of the header or the longest line length.</li>
137     * <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
138     * size.
139     * </ul>
140     * <p>
141     * 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.
142     * </p>
143     *
144     * @param table the table to adjust.
145     * @return a new TableDefinition with adjusted values.
146     */
147    protected TableDefinition adjustTableFormat(final TableDefinition table) {
148        final List<TextStyle.Builder> styleBuilders = new ArrayList<>();
149        for (int i = 0; i < table.columnTextStyles().size(); i++) {
150            final TextStyle style = table.columnTextStyles().get(i);
151            final TextStyle.Builder builder = TextStyle.builder().setTextStyle(style);
152            styleBuilders.add(builder);
153            final String header = table.headers().get(i);
154
155            if (style.getMaxWidth() < header.length() || style.getMaxWidth() == TextStyle.UNSET_MAX_WIDTH) {
156                builder.setMaxWidth(header.length());
157            }
158            if (style.getMinWidth() < header.length()) {
159                builder.setMinWidth(header.length());
160            }
161            for (final List<String> row : table.rows()) {
162                final String cell = row.get(i);
163                if (cell.length() > builder.getMaxWidth()) {
164                    builder.setMaxWidth(cell.length());
165                }
166            }
167        }
168        // calculate the total width.
169        int calcWidth = 0;
170        int adjustedMaxWidth = textStyleBuilder.getMaxWidth();
171        for (final TextStyle.Builder builder : styleBuilders) {
172            adjustedMaxWidth -= builder.getLeftPad();
173            if (builder.isScalable()) {
174                calcWidth += builder.getMaxWidth();
175            } else {
176                adjustedMaxWidth -= builder.getMaxWidth();
177            }
178        }
179        // rescale if necessary
180        if (calcWidth > adjustedMaxWidth) {
181            final double fraction = adjustedMaxWidth * 1.0 / calcWidth;
182            for (int i = 0; i < styleBuilders.size(); i++) {
183                final TextStyle.Builder builder = styleBuilders.get(i);
184                if (builder.isScalable()) {
185                    // resize and remove the padding from the maxWidth calculation.
186                    styleBuilders.set(i, resize(builder, fraction));
187                }
188            }
189        }
190        // regenerate the styles
191        final List<TextStyle> styles = new ArrayList<>();
192        for (final TextStyle.Builder builder : styleBuilders) {
193            // adjust by removing the padding as it was not accounted for above.
194            styles.add(builder.get());
195        }
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        for (final TextStyle style : table.columnTextStyles()) {
246            headerStyles.add(TextStyle.builder().setTextStyle(style).setAlignment(TextStyle.Alignment.CENTER).get());
247        }
248        writeColumnQueues(makeColumnQueues(table.headers(), headerStyles), headerStyles);
249        for (final List<String> row : table.rows()) {
250            writeColumnQueues(makeColumnQueues(row, table.columnTextStyles()), table.columnTextStyles());
251        }
252        output.append(System.lineSeparator());
253    }
254
255    @Override
256    public void appendTitle(final CharSequence title) throws IOException {
257        if (!Util.isEmpty(title)) {
258            final TextStyle style = textStyleBuilder.get();
259            final Queue<String> queue = makeColumnQueue(title, style);
260            queue.add(Util.repeatSpace(style.getLeftPad()) + Util.repeat(Math.min(title.length(), style.getMaxWidth()), '#'));
261            queue.add(BLANK_LINE);
262            printQueue(queue);
263        }
264    }
265
266    /**
267     * Gets the indent for the output.
268     *
269     * @return the indent of the page.
270     */
271    public int getIndent() {
272        return textStyleBuilder.getIndent();
273    }
274
275    /**
276     * Returns the left padding for the output.
277     *
278     * @return The left padding for the output.
279     */
280    public int getLeftPad() {
281        return textStyleBuilder.getLeftPad();
282    }
283
284    /**
285     * Gets the maximum width for the output
286     *
287     * @return the maximum width for the output.
288     */
289    public int getMaxWidth() {
290        return textStyleBuilder.getMaxWidth();
291    }
292
293    /**
294     * Gets the style builder used to format text that is not otherwise formatted.
295     *
296     * @return The style builder used to format text that is not otherwise formatted.
297     */
298    public TextStyle.Builder getTextStyleBuilder() {
299        return textStyleBuilder;
300    }
301
302    /**
303     * Creates a queue comprising strings extracted from columnData where the alignment and length are determined by the style.
304     *
305     * @param columnData The string to wrap
306     * @param style      The TextStyle to guide the wrapping.
307     * @return A queue of the string wrapped.
308     */
309    protected Queue<String> makeColumnQueue(final CharSequence columnData, final TextStyle style) {
310        final String lpad = Util.repeatSpace(style.getLeftPad());
311        final String indent = Util.repeatSpace(style.getIndent());
312        final Queue<String> result = new LinkedList<>();
313        int wrapPos = 0;
314        int nextPos;
315        final int wrappedMaxWidth = style.getMaxWidth() - indent.length();
316        while (wrapPos < columnData.length()) {
317            final int workingWidth = wrapPos == 0 ? style.getMaxWidth() : wrappedMaxWidth;
318            nextPos = indexOfWrap(columnData, workingWidth, wrapPos);
319            final CharSequence working = columnData.subSequence(wrapPos, nextPos);
320            result.add(lpad + style.pad(wrapPos > 0, working));
321            wrapPos = Util.indexOfNonWhitespace(columnData, nextPos);
322            wrapPos = wrapPos == -1 ? nextPos : wrapPos;
323        }
324        return result;
325    }
326
327    /**
328     * 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
329     * + the left pad.
330     *
331     * @param columnData The column data to output.
332     * @param styles     the styles to apply.
333     * @return A list of queues of strings that represent each column in the table.
334     */
335    protected List<Queue<String>> makeColumnQueues(final List<String> columnData, final List<TextStyle> styles) {
336        final List<Queue<String>> result = new ArrayList<>();
337        for (int i = 0; i < columnData.size(); i++) {
338            result.add(makeColumnQueue(columnData.get(i), styles.get(i)));
339        }
340        return result;
341    }
342
343    /**
344     * Prints a queue of text.
345     *
346     * @param queue the queue of text to print.
347     * @throws IOException on output error.
348     */
349    private void printQueue(final Queue<String> queue) throws IOException {
350        for (final String s : queue) {
351            appendFormat("%s%n", Util.rtrim(s));
352        }
353    }
354
355    /**
356     * Prints wrapped text using the TextHelpAppendable output style.
357     *
358     * @param text the text to wrap
359     * @throws IOException on output error.
360     */
361    public void printWrapped(final String text) throws IOException {
362        printQueue(makeColumnQueue(text, this.textStyleBuilder.get()));
363    }
364
365    /**
366     * Prints wrapped text.
367     *
368     * @param text  the text to wrap
369     * @param style the style for the wrapped text.
370     * @throws IOException on output error.
371     */
372    public void printWrapped(final String text, final TextStyle style) throws IOException {
373        printQueue(makeColumnQueue(text, style));
374    }
375
376    /**
377     * Resizes an original width based on the fractional size it should be.
378     *
379     * @param orig     the original size.
380     * @param fraction the fractional adjustment.
381     * @return the resized value.
382     */
383    private int resize(final int orig, final double fraction) {
384        return (int) (orig * fraction);
385    }
386
387    /**
388     * Resizes a TextStyle builder based on the fractional size.
389     *
390     * @param builder  the builder to adjust.
391     * @param fraction the fractional size (for example percentage of the current size) that the builder should be.
392     * @return the builder with the maximum width and indent values resized.
393     */
394    protected TextStyle.Builder resize(final TextStyle.Builder builder, final double fraction) {
395        final double indentFrac = builder.getIndent() * 1.0 / builder.getMaxWidth();
396        builder.setMaxWidth(Math.max(resize(builder.getMaxWidth(), fraction), builder.getMinWidth()));
397        final int maxAdjust = builder.getMaxWidth() / 3;
398        int newIndent = builder.getMaxWidth() == 1 ? 0 : builder.getIndent();
399        if (newIndent > maxAdjust) {
400            newIndent = Math.min(resize(builder.getIndent(), indentFrac), maxAdjust);
401        }
402        builder.setIndent(newIndent);
403        return builder;
404    }
405
406    /**
407     * Sets the indent for the output.
408     *
409     * @param indent the indent used for paragraphs.
410     */
411    public void setIndent(final int indent) {
412        textStyleBuilder.setIndent(indent);
413    }
414
415    /**
416     * Sets the left padding: the number of characters from the left edge to start output.
417     *
418     * @param leftPad the left padding.
419     */
420    public void setLeftPad(final int leftPad) {
421        textStyleBuilder.setLeftPad(leftPad);
422    }
423
424    /**
425     * Sets the maximum width for the output.
426     *
427     * @param maxWidth the maximum width for the output.
428     */
429    public void setMaxWidth(final int maxWidth) {
430        textStyleBuilder.setMaxWidth(maxWidth);
431    }
432
433    /**
434     * Writes one line from each of the {@code columnQueues} until all the queues are exhausted. If an exhausted queue is encountered while other queues
435     * continue to have content the exhausted queue will produce empty text for the output width of the column (maximum width + left pad).
436     *
437     * @param columnQueues the List of queues that represent the columns of data.
438     * @param styles       the TextStyle for each column.
439     * @throws IOException on output error.
440     */
441    protected void writeColumnQueues(final List<Queue<String>> columnQueues, final List<TextStyle> styles) throws IOException {
442        boolean moreData = true;
443        final String lPad = Util.repeatSpace(textStyleBuilder.get().getLeftPad());
444        while (moreData) {
445            output.append(lPad);
446            moreData = false;
447            for (int i = 0; i < columnQueues.size(); i++) {
448                final TextStyle style = styles.get(i);
449                final Queue<String> columnQueue = columnQueues.get(i);
450                final String line = columnQueue.poll();
451                if (Util.isEmpty(line)) {
452                    output.append(Util.repeatSpace(style.getMaxWidth() + style.getLeftPad()));
453                } else {
454                    output.append(line);
455                }
456                moreData |= !columnQueue.isEmpty();
457            }
458            output.append(System.lineSeparator());
459        }
460    }
461}