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}