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}