View Javadoc
1   /*
2     Licensed to the Apache Software Foundation (ASF) under one or more
3     contributor license agreements.  See the NOTICE file distributed with
4     this work for additional information regarding copyright ownership.
5     The ASF licenses this file to You under the Apache License, Version 2.0
6     (the "License"); you may not use this file except in compliance with
7     the License.  You may obtain a copy of the License at
8   
9         https://www.apache.org/licenses/LICENSE-2.0
10  
11    Unless required by applicable law or agreed to in writing, software
12    distributed under the License is distributed on an "AS IS" BASIS,
13    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14    See the License for the specific language governing permissions and
15    limitations under the License.
16   */
17  package org.apache.commons.cli.help;
18  
19  import java.io.IOException;
20  import java.util.ArrayList;
21  import java.util.Arrays;
22  import java.util.Collection;
23  import java.util.Collections;
24  import java.util.HashSet;
25  import java.util.LinkedList;
26  import java.util.List;
27  import java.util.Queue;
28  import java.util.Set;
29  
30  /**
31   * Writes text format output.
32   *
33   * @since 1.10.0
34   */
35  public class TextHelpAppendable extends FilterHelpAppendable {
36  
37      /** The default number of characters per line: {@value}. */
38      public static final int DEFAULT_WIDTH = 74;
39  
40      /** The default padding to the left of each line: {@value}. */
41      public static final int DEFAULT_LEFT_PAD = 1;
42  
43      /** The number of space characters to be prefixed to each description line: {@value}. */
44      public static final int DEFAULT_INDENT = 3;
45  
46      /** The number of space characters before a list continuation line: {@value}. */
47      public static final int DEFAULT_LIST_INDENT = 7;
48  
49      /** A blank line in the output: {@value}. */
50      private static final String BLANK_LINE = "";
51  
52      /** The set of characters that are breaks in text. */
53      // @formatter:off
54      private static final Set<Character> BREAK_CHAR_SET = Collections.unmodifiableSet(new HashSet<>(Arrays.asList('\t', '\n', '\f', '\r',
55              (char) Character.LINE_SEPARATOR,
56              (char) Character.PARAGRAPH_SEPARATOR,
57              '\u000b', // VERTICAL TABULATION.
58              '\u001c', // FILE SEPARATOR.
59              '\u001d', // GROUP SEPARATOR.
60              '\u001e', // RECORD SEPARATOR.
61              '\u001f' // UNIT SEPARATOR.
62      )));
63      // @formatter:on
64  
65      /**
66       * 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
67       * position before startPos+width having a whitespace character (space, \n, \r). If there is no whitespace character before startPos+width, it will return
68       * startPos+width.
69       *
70       * @param text     The text being searched for the wrap position
71       * @param width    width of the wrapped text
72       * @param startPos position from which to start the lookup whitespace character
73       * @return position on which the text must be wrapped or @{code text.length()} if the wrap position is at the end of the text.
74       */
75      public static int indexOfWrap(final CharSequence text, final int width, final int startPos) {
76          if (width < 1) {
77              throw new IllegalArgumentException("Width must be greater than 0");
78          }
79          // handle case of width > text.
80          // the line ends before the max wrap pos or a new line char found
81          int limit = Math.min(startPos + width, text.length());
82          for (int idx = startPos; idx < limit; idx++) {
83              if (BREAK_CHAR_SET.contains(text.charAt(idx))) {
84                  return idx;
85              }
86          }
87          if (startPos + width >= text.length()) {
88              return text.length();
89          }
90  
91          limit = Math.min(startPos + width, text.length() - 1);
92          int pos;
93          // look for the last whitespace character before limit
94          for (pos = limit; pos >= startPos; --pos) {
95              if (Util.isWhitespace(text.charAt(pos))) {
96                  break;
97              }
98          }
99          // 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 }