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 * http://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.juneau.commons.lang; 018 019import static org.apache.juneau.commons.lang.StateEnum.*; 020import static org.apache.juneau.commons.utils.AssertionUtils.*; 021import static org.apache.juneau.commons.utils.CollectionUtils.*; 022import static org.apache.juneau.commons.utils.Utils.*; 023 024import java.text.*; 025import java.util.*; 026import java.util.stream.*; 027 028import org.apache.juneau.commons.collections.*; 029import org.apache.juneau.commons.utils.*; 030 031import java.util.MissingFormatArgumentException; 032 033/** 034 * Unified string formatter supporting both MessageFormat-style and printf-style formatting in the same pattern. 035 * 036 * <p> 037 * This class provides a thread-safe, cacheable formatter that can handle mixed format styles in a single pattern. 038 * It supports both MessageFormat syntax (<js>"{0}"</js>, <js>"{1,number}"</js>) and printf syntax (<js>"%s"</js>, <js>"%d"</js>) 039 * within the same string. 040 * 041 * <h5 class='section'>Features:</h5> 042 * <ul> 043 * <li><b>Dual Format Support:</b> Mix MessageFormat and printf-style placeholders in the same pattern</li> 044 * <li><b>Thread-Safe:</b> Immutable class, safe for concurrent use</li> 045 * <li><b>Cacheable:</b> Use {@link #of(String)} for cached instances</li> 046 * <li><b>Argument Sharing:</b> Both format styles share the same argument array</li> 047 * </ul> 048 * 049 * <h5 class='section'>Format Style Detection:</h5> 050 * <p> 051 * The formatter automatically detects which style to use for each placeholder: 052 * <ul> 053 * <li><b>MessageFormat style:</b> <js>"{0}"</js>, <js>"{1,number}"</js>, <js>"{2,date}"</js>, etc.</li> 054 * <li><b>Printf style:</b> <js>"%s"</js>, <js>"%d"</js>, <js>"%.2f"</js>, <js>"%1$s"</js>, etc.</li> 055 * </ul> 056 * 057 * <h5 class='section'>Argument Mapping:</h5> 058 * <p> 059 * Arguments are processed in order of appearance: 060 * <ul> 061 * <li><b>MessageFormat placeholders:</b> Use explicit indices (e.g., <js>"{0}"</js> uses <c>args[0]</c>)</li> 062 * <li><b>Printf placeholders:</b> Use sequential indices starting after the highest MessageFormat index</li> 063 * </ul> 064 * 065 * <h5 class='section'>Examples:</h5> 066 * <p class='bjava'> 067 * <jc>// Mixed format styles</jc> 068 * StringFormat <jv>fmt</jv> = StringFormat.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>); 069 * String <jv>result</jv> = <jv>fmt</jv>.<jsm>format</jsm>(<js>"John"</js>, 5); 070 * <jc>// Returns: "Hello John, you have 5 items"</jc> 071 * 072 * <jc>// MessageFormat with explicit indices, printf with sequential</jc> 073 * StringFormat <jv>fmt2</jv> = StringFormat.<jsm>of</jsm>(<js>"User {0} has %s and {1} items"</js>); 074 * String <jv>result2</jv> = <jv>fmt2</jv>.<jsm>format</jsm>(<js>"Alice"</js>, 10, <js>"admin"</js>); 075 * <jc>// Returns: "User Alice has admin and 10 items"</jc> 076 * <jc>// {0} -> "Alice", {1} -> 10, %s -> "admin"</jc> 077 * 078 * <jc>// Printf with explicit indices</jc> 079 * StringFormat <jv>fmt3</jv> = StringFormat.<jsm>of</jsm>(<js>"%1$s loves %2$s, and {0} also loves %3$s"</js>); 080 * String <jv>result3</jv> = <jv>fmt3</jv>.<jsm>format</jsm>(<js>"Alice"</js>, <js>"Bob"</js>, <js>"Charlie"</js>); 081 * <jc>// Returns: "Alice loves Bob, and Alice also loves Charlie"</jc> 082 * </p> 083 * 084 * <h5 class='section'>Caching:</h5> 085 * <p> 086 * Use {@link #of(String)} to get cached instances. The cache is thread-safe and limited to 1000 entries. 087 * For uncached instances, use the constructor directly. 088 * </p> 089 * 090 * <h5 class='section'>Thread Safety:</h5> 091 * <p> 092 * This class is immutable and thread-safe. Multiple threads can safely use the same instance concurrently. 093 * </p> 094 * 095 * @see StringUtils#format(String, Object...) 096 */ 097public final class StringFormat { 098 099 /** 100 * Literal text token. 101 */ 102 private static final class LiteralToken extends Token { 103 private final String text; 104 105 LiteralToken(String text) { 106 this.text = text; 107 } 108 109 @Override 110 public String toString() { 111 return "[L:" + text + "]"; 112 } 113 114 @Override 115 void append(StringBuilder sb, Object[] args, Locale locale) { 116 sb.append(text); 117 } 118 } 119 120 /** 121 * MessageFormat-style token (e.g., {0}, {1,number}). 122 */ 123 private static final class MessageFormatToken extends Token { 124 private final char format; 125 private final String content; // null for simple tokens, normalized pattern like "{0,number}" for complex tokens 126 private final int index; // 0-based index 127 private final String placeholder; // Original placeholder text like "{0}" or "{0,number}" 128 129 /** 130 * @param content - The variable content such as "{}" or "{0}", or "{0,number}" 131 * The content should have the curly-braces already removed so that we're only looking at the inner parts. 132 * @param index - The zero-based index of the variable in the message (used for sequential {} placeholders). 133 */ 134 MessageFormatToken(String content, int index) { 135 if (content.isBlank()) { 136 this.content = null; 137 this.index = index; 138 this.format = 's'; 139 this.placeholder = "{" + index + "}"; 140 } else if (content.indexOf(',') == -1) { 141 this.content = null; 142 this.index = parseIndexMF(content); 143 this.format = 's'; 144 this.placeholder = "{" + this.index + "}"; 145 } else { 146 var tokens = content.split(",", 2); 147 this.index = parseIndexMF(tokens[0]); 148 this.content = "{0," + tokens[1] + "}"; 149 this.format = 'o'; 150 this.placeholder = "{" + this.index + "," + tokens[1] + "}"; 151 } 152 } 153 154 @Override 155 public String toString() { 156 return "[M:" + format + index + (content == null ? "" : (':' + content)) + "]"; 157 } 158 159 @Override 160 void append(StringBuilder sb, Object[] args, Locale locale) { 161 // MessageFormat inserts the placeholder text if argument is missing 162 if (args == null || index >= args.length || index < 0) { 163 sb.append(placeholder); 164 return; 165 } 166 var o = args[index]; 167 var l = locale == null ? Locale.getDefault() : locale; 168 switch (format) { 169 case 's': 170 if (o == null) { 171 sb.append("null"); 172 } else if (o instanceof Number o2) { 173 sb.append(NUMBER_FORMAT_CACHE.get(l).format(o2)); 174 } else if (o instanceof Date o2) { 175 sb.append(DATE_FORMAT_CACHE.get(l).format(o2)); 176 } else { 177 sb.append(o.toString()); 178 } 179 break; 180 default: 181 // Use Cache2 with Locale and content as separate keys to avoid string concatenation 182 var mf = MESSAGE_FORMAT_CACHE.get(l, content); 183 sb.append(mf.format(a(o))); 184 break; 185 } 186 } 187 } 188 189 /** 190 * Printf-style token (e.g., %s, %d, %.2f). 191 */ 192 private static final class StringFormatToken extends Token { 193 private final char format; // 's' = simple (handle directly), 'o' = other (use String.format) 194 private final String content; // The format string to pass to String.format (null for simple formats) 195 private final int index; // 0-based index 196 197 StringFormatToken(String content, int index) { 198 // content is everything after '%' (e.g., "s", "1$s", "d", ".2f", "1$.2f") 199 var $ = content.indexOf('$'); 200 if ($ >= 0) { 201 index = parseIndexSF(content.substring(0, $)) - 1; 202 content = content.substring($ + 1); 203 } 204 this.format = content.length() == 1 ? content.charAt(content.length() - 1) : 'z'; 205 this.index = index; 206 this.content = "%" + content; 207 } 208 209 @Override 210 public String toString() { 211 return "[S:" + format + index + ":" + content + "]"; 212 } 213 214 @Override 215 void append(StringBuilder sb, Object[] args, Locale locale) { 216 // String.format() throws MissingFormatArgumentException when argument is missing 217 if (args == null || index >= args.length || index < 0) { 218 throw new MissingFormatArgumentException(content); 219 } 220 var o = args[index]; 221 var l = locale == null ? Locale.getDefault() : locale; 222 var dl = locale == null || locale.equals(Locale.getDefault()); 223 switch (format) { 224 case 'b': 225 // String.format() with %b converts: 226 // - null -> "false" 227 // - Boolean -> toString() 228 // - Any other non-null value -> "true" 229 if (o == null) { 230 sb.append("false"); 231 } else if (o instanceof Boolean) { 232 sb.append(o.toString()); 233 } else { 234 sb.append("true"); 235 } 236 return; 237 case 'B': 238 // String.format() with %B converts: 239 // - null -> "FALSE" 240 // - Boolean -> toString().toUpperCase() 241 // - Any other non-null value -> "TRUE" 242 if (o == null) { 243 sb.append("FALSE"); 244 } else if (o instanceof Boolean) { 245 sb.append(o.toString().toUpperCase()); 246 } else { 247 sb.append("TRUE"); 248 } 249 return; 250 case 's': 251 if (o == null) { 252 sb.append("null"); 253 return; 254 } 255 sb.append(o.toString()); 256 return; 257 case 'S': 258 if (o == null) { 259 sb.append("NULL"); 260 return; 261 } 262 sb.append(o.toString().toUpperCase()); 263 return; 264 case 'd': 265 if (o == null) { 266 sb.append("null"); 267 return; 268 } 269 if (o instanceof Number o2) { 270 if (dl) { 271 if (o instanceof Integer || o instanceof Long || o instanceof Byte || o instanceof Short) { 272 sb.append(o); 273 } else { 274 // For other Number types (BigDecimal, BigInteger, etc.), convert to long 275 sb.append(o2.longValue()); 276 } 277 return; 278 } 279 // For non-default locales, use String.format to ensure printf-style consistency 280 sb.append(sf(l, "%d", o)); 281 return; 282 } 283 break; 284 case 'x': 285 if (o == null) { 286 sb.append("null"); 287 return; 288 } 289 if (o instanceof Integer o2) { 290 sb.append(Integer.toHexString(o2)); 291 return; 292 } else if (o instanceof Long o2) { 293 sb.append(Long.toHexString(o2)); 294 return; 295 } 296 break; 297 case 'X': 298 if (o == null) { 299 sb.append("NULL"); 300 return; 301 } 302 if (o instanceof Integer o2) { 303 sb.append(Integer.toHexString(o2).toUpperCase()); 304 return; 305 } else if (o instanceof Long o2) { 306 sb.append(Long.toHexString(o2).toUpperCase()); 307 return; 308 } 309 break; 310 case 'o': 311 if (o == null) { 312 sb.append("null"); 313 return; 314 } 315 if (o instanceof Integer o2) { 316 sb.append(Integer.toOctalString(o2)); 317 return; 318 } else if (o instanceof Long o2) { 319 sb.append(Long.toOctalString(o2)); 320 return; 321 } 322 break; 323 case 'c': 324 if (o == null) { 325 sb.append("null"); 326 return; 327 } 328 if (o instanceof Character) { 329 sb.append(o); 330 return; 331 } else if (o instanceof Integer o2) { 332 sb.append((char)o2.intValue()); 333 return; 334 } 335 break; 336 case 'C': 337 if (o == null) { 338 sb.append("NULL"); 339 return; 340 } 341 if (o instanceof Character o2) { 342 sb.append(Character.toUpperCase(o2)); 343 return; 344 } else if (o instanceof Integer o2) { 345 sb.append(Character.toUpperCase((char)o2.intValue())); 346 return; 347 } 348 break; 349 case 'f': 350 if (o == null) { 351 sb.append("null"); 352 return; 353 } 354 // Always use String.format() to match exact behavior (precision, etc.) 355 if (o instanceof Number) { 356 sb.append(sf(l, "%f", o)); 357 return; 358 } 359 break; 360 default: 361 break; 362 } 363 364 // Fallback to String.format for any other simple format 365 sb.append(sf(l, content, o)); 366 } 367 } 368 369 /** 370 * Base class for format tokens. 371 */ 372 private abstract static class Token { 373 /** 374 * Appends the formatted content to the StringBuilder. 375 * 376 * @param sb The StringBuilder to append to. 377 * @param args The arguments array. 378 * @param locale The locale for formatting (can be null for default). 379 */ 380 abstract void append(StringBuilder sb, Object[] args, Locale locale); 381 } 382 383 private static final CacheMode CACHE_MODE = CacheMode.parse(System.getProperty("juneau.StringFormat.caching", "FULL")); 384 385 private static final Cache<String,StringFormat> CACHE = Cache.of(String.class, StringFormat.class).maxSize(1000).cacheMode(CACHE_MODE).build(); 386 private static final Cache2<Locale,String,MessageFormat> MESSAGE_FORMAT_CACHE = Cache2.of(Locale.class, String.class, MessageFormat.class).maxSize(100).threadLocal().cacheMode(CACHE_MODE) 387 .supplier((locale, content) -> new MessageFormat(content, locale)).build(); 388 389 private static final Cache<Locale,NumberFormat> NUMBER_FORMAT_CACHE = Cache.of(Locale.class, NumberFormat.class).maxSize(50).threadLocal().cacheMode(CACHE_MODE).supplier(NumberFormat::getInstance) 390 .build(); 391 392 private static final Cache<Locale,DateFormat> DATE_FORMAT_CACHE = Cache.of(Locale.class, DateFormat.class).maxSize(50).threadLocal().cacheMode(CACHE_MODE) 393 .supplier(locale -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale)).build(); 394 395 private static final AsciiSet PRINTF_CONVERSION_CHARS = AsciiSet.of("bBhHsScCdoxXeEfgGaAtTn%"); 396 397 private static final AsciiSet PRINTF_FORMAT_CHARS = AsciiSet.of("-+ 0(#.*$"); 398 399 /** 400 * Formats a pattern string with the given arguments using the specified locale. 401 * 402 * <p> 403 * This is a convenience method that creates a StringFormat instance and formats it. 404 * If no arguments are passed in, the pattern is returned as-is. 405 * 406 * @param pattern The format pattern. 407 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale. 408 * @param args The arguments to format. 409 * @return The formatted string. 410 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid. 411 */ 412 public static String format(String pattern, Locale locale, Object...args) { 413 if (args.length == 0) 414 return pattern; 415 return of(pattern).format(locale, args); 416 } 417 418 /** 419 * Formats a pattern string with the given arguments using the default locale. 420 * 421 * <p> 422 * This is a convenience method that creates a StringFormat instance and formats it. 423 * If no arguments are passed in, the pattern is simply returned as-is. 424 * 425 * @param pattern The format pattern. 426 * @param args The arguments to format. 427 * @return The formatted string. 428 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid. 429 */ 430 public static String format(String pattern, Object...args) { 431 if (args.length == 0) 432 return pattern; 433 return of(pattern).format(args); 434 } 435 436 /** 437 * Returns a cached StringFormat instance for the given pattern. 438 * 439 * <p> 440 * This method uses a thread-safe cache to avoid recreating StringFormat instances for the same pattern. 441 * The cache is limited to 1000 entries. 442 * 443 * @param pattern The format pattern. 444 * @return A cached or new StringFormat instance. 445 * @throws IllegalArgumentException If the pattern is <jk>null</jk>. 446 */ 447 public static StringFormat of(String pattern) { 448 assertArgNotNull("pattern", pattern); 449 return CACHE.get(pattern, () -> new StringFormat(pattern)); 450 } 451 452 private static void lit(List<Token> tokens, String pattern, int start) { 453 if (start == pattern.length()) 454 return; 455 tokens.add(new LiteralToken(pattern.substring(start))); 456 } 457 458 private static void lit(List<Token> tokens, String pattern, int start, int end) { 459 if (start == end) 460 return; 461 tokens.add(new LiteralToken(pattern.substring(start, end))); 462 } 463 464 private static void mf(List<Token> tokens, String pattern, int start, int end, int index) { 465 tokens.add(new MessageFormatToken(pattern.substring(start, end), index)); 466 } 467 468 private static int parseIndexMF(String s) { 469 if (! s.matches("[0-9]+")) throw new IllegalArgumentException("can't parse argument number: " + s); 470 return Integer.parseInt(s); 471 } 472 473 private static int parseIndexSF(String s) { 474 return Integer.parseInt(s); 475 } 476 477 /** 478 * Parses the pattern into a list of tokens. 479 */ 480 private static List<Token> parseTokens(String pattern) { 481 var tokens = new ArrayList<Token>(); 482 var length = pattern.length(); 483 var i = 0; 484 var sequentialIndex = 0; // 0-based index for sequential placeholders 485 486 // Possible String.format variable formats: 487 // %[argument_index$][flags][width][.precision]conversion 488 // %[argument_index$][flags][width]conversion 489 // %[flags][width]conversion 490 491 // Possible MessageFormat variable formats: 492 // {} 493 // {#,formatType} 494 // {#,formatType,formatStyle} 495 496 // S1 - In literal, looking for %, {, or ' 497 // S2 - Found %, looking for conversion char or t or T 498 // S3 - Found {, looking for } 499 // S4 - Found ', in quoted section (MessageFormat single quotes escape special chars), looking for ' 500 var state = S1; 501 502 var nestedBracketDepth = 0; 503 504 var mark = 0; 505 while (i < length) { 506 var ch = pattern.charAt(i++); 507 508 if (state == S1) { 509 if (ch == '%') { 510 lit(tokens, pattern, mark, i - 1); 511 state = S2; 512 mark = i; 513 } else if (ch == '{') { 514 lit(tokens, pattern, mark, i - 1); 515 state = S3; 516 mark = i - 1; 517 } else if (ch == '\'') { 518 lit(tokens, pattern, mark, i - 1); 519 state = S4; 520 mark = i; 521 } 522 } else if (state == S2) { 523 if (ch == '%') { 524 tokens.add(new LiteralToken("%")); 525 state = S1; 526 mark = i; 527 } else if (ch == 'n') { 528 // %n is special - it doesn't consume an argument, so handle it as a literal token 529 tokens.add(new LiteralToken(System.lineSeparator())); 530 state = S1; 531 mark = i; 532 } else if (ch == 't' || ch == 'T') { 533 // Do nothing. Part of 2-character time conversion. 534 } else if (PRINTF_CONVERSION_CHARS.contains(ch)) { 535 sf(tokens, pattern, mark, i, sequentialIndex++); 536 state = S1; 537 mark = i; 538 } else if (PRINTF_FORMAT_CHARS.contains(ch) || Character.isDigit(ch)) { 539 // Do nothing. 540 } else { 541 // Unknown character - could be invalid conversion or end of format 542 // Create StringFormatToken and let String.format() validate it 543 // This allows String.format() to throw IllegalFormatException for invalid conversions like %F 544 // printfStart is position after '%', so substring from printfStart-1 (the '%') to i (after the char) 545 sf(tokens, pattern, mark, i, sequentialIndex++); 546 state = S1; 547 mark = i; 548 } 549 } else if (state == S3) { 550 if (ch == '{') { 551 nestedBracketDepth++; 552 } else if (ch == '}') { 553 if (nestedBracketDepth > 0) { 554 nestedBracketDepth--; 555 } else { 556 mf(tokens, pattern, mark + 1, i - 1, sequentialIndex++); 557 state = S1; 558 mark = i; 559 } 560 } 561 } else /* if (state == S4) */ { 562 if (ch == '\'') { 563 if (mark == i - 1) { 564 lit(tokens, pattern, mark, i); // '' becomes ' 565 state = S1; 566 mark = i; 567 } else { 568 lit(tokens, pattern, mark, i - 1); 569 state = S1; 570 mark = i; 571 } 572 } 573 } 574 } 575 576 // Process remaining content based on final state 577 if (state == S1) { 578 lit(tokens, pattern, mark); 579 } else if (state == S2) { 580 // Dangling '%' without conversion - throw exception to match String.format() behavior 581 // UnknownFormatConversionException constructor takes just the conversion character 582 throw new java.util.UnknownFormatConversionException("%"); 583 } else if (state == S3) { 584 // Unmatched '{' - throw exception to match MessageFormat behavior 585 throw new IllegalArgumentException("Unmatched braces in the pattern."); 586 } else /* if (state == S4) */ { 587 // Unmatched quote - MessageFormat treats it as ending the quoted section 588 // Add the quoted content as literal (from mark to end of pattern) 589 lit(tokens, pattern, mark); 590 } 591 592 return tokens; 593 } 594 595 private static void sf(List<Token> tokens, String pattern, int start, int end, int index) { 596 tokens.add(new StringFormatToken(pattern.substring(start, end), index)); 597 } 598 599 private static String sf(Locale l, String s, Object o) { 600 return String.format(l, s, a(o)); 601 } 602 603 private final String pattern; 604 605 private final Token[] tokens; 606 607 /** 608 * Creates a new StringFormat instance. 609 * 610 * @param pattern The format pattern. Can contain both MessageFormat and printf-style placeholders. 611 * @throws IllegalArgumentException If the pattern is <jk>null</jk>. 612 */ 613 public StringFormat(String pattern) { 614 this.pattern = assertArgNotNull("pattern", pattern); 615 this.tokens = parseTokens(pattern).toArray(Token[]::new); 616 } 617 618 @Override 619 public boolean equals(Object o) { 620 return o instanceof StringFormat o2 && eq(this, o2, (x, y) -> eq(x.pattern, y.pattern)); 621 } 622 623 /** 624 * Formats the pattern with the given arguments using the specified locale. 625 * 626 * <p> 627 * The locale affects both MessageFormat and printf-style formatting: 628 * <ul> 629 * <li><b>MessageFormat:</b> Locale-specific number, date, and time formatting</li> 630 * <li><b>Printf:</b> Locale-specific number formatting (decimal separators, etc.)</li> 631 * </ul> 632 * 633 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale. 634 * @param args The arguments to format. 635 * @return The formatted string. 636 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match. 637 */ 638 public String format(Locale locale, Object...args) { 639 var sb = new StringBuilder(pattern.length() + 64); 640 for (var token : tokens) { 641 token.append(sb, args, locale); 642 } 643 return sb.toString(); 644 } 645 646 /** 647 * Formats the pattern with the given arguments using the default locale. 648 * 649 * @param args The arguments to format. 650 * @return The formatted string. 651 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match. 652 */ 653 public String format(Object...args) { 654 return format(Locale.getDefault(), args); 655 } 656 657 @Override 658 public int hashCode() { 659 return pattern.hashCode(); 660 } 661 662 /** 663 * Returns a debug representation of the parsed pattern showing the token structure. 664 * 665 * <p> 666 * This method is useful for debugging and understanding how a pattern was parsed. 667 * It returns a string showing each token in the format: 668 * <ul> 669 * <li><b>Literal tokens:</b> <js>"[L:text]"</js> - Literal text</li> 670 * <li><b>MessageFormat tokens (simple):</b> <js>"[M:s0]"</js> - Simple MessageFormat placeholder (format='s', index=0)</li> 671 * <li><b>MessageFormat tokens (complex):</b> <js>"[M:o0:{0,number,currency}]"</js> - Complex MessageFormat placeholder (format='o', index=0, content)</li> 672 * <li><b>StringFormat tokens (simple):</b> <js>"[S:s0:%s]"</js> - Simple printf placeholder (format='s', index=0, content)</li> 673 * <li><b>StringFormat tokens (complex):</b> <js>"[S:z0:%.2f]"</js> - Complex printf placeholder (format='z', index=0, content)</li> 674 * </ul> 675 * 676 * <h5 class='section'>Token Format:</h5> 677 * <ul> 678 * <li><b>L</b> = Literal token</li> 679 * <li><b>M</b> = MessageFormat token</li> 680 * <li><b>S</b> = StringFormat (printf) token</li> 681 * <li><b>Format character:</b> 's' = simple, 'o' = other/complex, 'z' = complex printf</li> 682 * <li><b>Index:</b> 0-based argument index</li> 683 * <li><b>Content:</b> The format string content (for complex tokens)</li> 684 * </ul> 685 * 686 * <h5 class='section'>Examples:</h5> 687 * <p class='bjava'> 688 * StringFormat <jv>fmt</jv> = StringFormat.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>); 689 * <jv>fmt</jv>.<jsm>toPattern</jsm>(); 690 * <jc>// Returns: "[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]"</jc> 691 * </p> 692 * 693 * @return A debug string showing the parsed token structure. 694 */ 695 public String toPattern() { 696 return Arrays.stream(tokens).map(Object::toString).collect(Collectors.joining()); 697 } 698 699 @Override 700 public String toString() { 701 return pattern; 702 } 703}