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}