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.Shorts.*;
021import static org.apache.juneau.commons.utils.SystemUtils.*;
022
023import java.util.*;
024import java.util.stream.*;
025
026import org.apache.juneau.commons.collections.*;
027import org.apache.juneau.commons.utils.*;
028
029/**
030 * Printf-style string formatter with a small cache and lenient argument handling.
031 *
032 * <p>
033 * This class provides a thread-safe, cacheable formatter for printf-style ({@link java.util.Formatter}) patterns
034 * (<js>"%s"</js>, <js>"%d"</js>, <js>"%.2f"</js>, <js>"%1$s"</js>, etc.). MessageFormat-style (<js>"{0}"</js>)
035 * patterns are <b>not</b> recognized here — <c>{</c> and <c>'</c> are treated as literal text. For MessageFormat
036 * rendering use {@link StringUtils#mformat(String, Object...)} / {@code Shorts.mf(...)}/{@code Shorts.mfs(...)}
037 * (backed by {@link java.text.MessageFormat}).
038 *
039 * <h5 class='section'>Features:</h5>
040 * <ul>
041 *   <li><b>Printf grammar:</b> <js>"%s"</js>, <js>"%d"</js>, <js>"%.2f"</js>, <js>"%1$s"</js>, etc.</li>
042 *   <li><b>Thread-Safe:</b> Immutable class, safe for concurrent use</li>
043 *   <li><b>Cacheable:</b> Use {@link #ofPrintf(String)} for cached instances</li>
044 *   <li><b>Lenient null handling:</b> <jk>null</jk> arguments render as <js>"null"</js> for string conversions
045 *       (rather than throwing) and a fast path avoids {@link java.util.Formatter} for simple <js>"%s"</js> tokens</li>
046 * </ul>
047 *
048 * <h5 class='section'>Examples:</h5>
049 * <p class='bjava'>
050 *    <jc>// Simple printf</jc>
051 *    StringFormat <jv>fmt</jv> = StringFormat.<jsm>ofPrintf</jsm>(<js>"Hello %s, you have %d items"</js>);
052 *    String <jv>result</jv> = <jv>fmt</jv>.<jsm>format</jsm>(<js>"John"</js>, 5);
053 *    <jc>// Returns: "Hello John, you have 5 items"</jc>
054 *
055 *    <jc>// Argument index (reuse arguments)</jc>
056 *    StringFormat <jv>fmt2</jv> = StringFormat.<jsm>ofPrintf</jsm>(<js>"%1$s loves %2$s, and %1$s also loves %3$s"</js>);
057 *    String <jv>result2</jv> = <jv>fmt2</jv>.<jsm>format</jsm>(<js>"Alice"</js>, <js>"Bob"</js>, <js>"Charlie"</js>);
058 *    <jc>// Returns: "Alice loves Bob, and Alice also loves Charlie"</jc>
059 * </p>
060 *
061 * <h5 class='section'>Caching:</h5>
062 * <p>
063 * Use {@link #ofPrintf(String)} to get cached instances. The cache is thread-safe and limited to 1000 entries.
064 * For uncached instances, use the constructor directly.
065 * </p>
066 *
067 * <h5 class='section'>Thread Safety:</h5>
068 * <p>
069 * This class is immutable and thread-safe. Multiple threads can safely use the same instance concurrently.
070 * </p>
071 *
072 * @see StringUtils#format(String, Object...)
073 * @see StringUtils#mformat(String, Object...)
074 */
075@SuppressWarnings({
076   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
077   "java:S3776", // Cognitive complexity acceptable for this specific logic
078   "java:S6541" // parseTokens() is a single-pass tokenizer over the whole MessageFormat/printf pattern grammar
079})
080public final class StringFormat {
081
082   /**
083    * Literal text token.
084    */
085   private static final class LiteralToken extends Token {
086      private final String text;
087
088      LiteralToken(String text) {
089         this.text = text;
090      }
091
092      @Override
093      public String toString() {
094         return "[L:" + text + "]";
095      }
096
097      @Override
098      void append(StringBuilder sb, Object[] args, Locale locale) {
099         sb.append(text);
100      }
101   }
102
103   /**
104    * Printf-style token (e.g., %s, %d, %.2f).
105    */
106   private static final class StringFormatToken extends Token {
107      private final char format; // 's' = simple (handle directly), 'o' = other (use String.format)
108      private final String content; // The format string to pass to String.format (null for simple formats)
109      private final int index; // 0-based index
110
111   StringFormatToken(String content, int index) {
112      // content is everything after '%' (e.g., "s", "1$s", "d", ".2f", "1$.2f")
113      var dollarIndex = content.indexOf('$');
114      if (dollarIndex >= 0) {
115         index = parseIndex(content.substring(0, dollarIndex)) - 1;
116         content = content.substring(dollarIndex + 1);
117      }
118         this.format = content.length() == 1 ? content.charAt(content.length() - 1) : 'z';
119         this.index = index;
120         this.content = "%" + content;
121      }
122
123      /**
124       * Parses the index from a StringFormat content string.
125       *
126       * @param s The content string (e.g., "1", "2").
127       * @return The parsed index.
128       */
129      private static int parseIndex(String s) {
130         return Integer.parseInt(s);
131      }
132
133      /**
134       * Creates a StringFormatToken and adds it to the tokens list.
135       *
136       * @param tokens The list of tokens to add to.
137       * @param pattern The pattern string.
138       * @param start The start index in the pattern.
139       * @param end The end index in the pattern.
140       * @param index The token index.
141       */
142      static void create(List<Token> tokens, String pattern, int start, int end, int index) {
143         tokens.add(new StringFormatToken(pattern.substring(start, end), index));
144      }
145
146      @Override
147      public String toString() {
148         return "[S:" + format + index + ":" + content + "]";
149      }
150
151      @Override
152      void append(StringBuilder sb, Object[] args, Locale locale) {
153         // String.format() throws MissingFormatArgumentException when argument is missing
154         if (args == null || index >= args.length || index < 0) {
155            throw new MissingFormatArgumentException(content);
156         }
157         var o = args[index];
158         var l = locale == null ? Locale.getDefault() : locale;
159         var dl = locale == null || locale.equals(Locale.getDefault());
160         switch (format) {
161            case 'b':
162               // String.format() with %b converts:
163               // - null -> "false"
164               // - Boolean -> toString()
165               // - Any other non-null value -> "true"
166               if (o == null) {
167                  sb.append("false");
168               } else if (o instanceof Boolean) {
169                  sb.append(o.toString());
170               } else {
171                  sb.append("true");
172               }
173               return;
174            case 'B':
175               // String.format() with %B converts:
176               // - null -> "FALSE"
177               // - Boolean -> toString().toUpperCase()
178               // - Any other non-null value -> "TRUE"
179               if (o == null) {
180                  sb.append("FALSE");
181               } else if (o instanceof Boolean) {
182                  sb.append(o.toString().toUpperCase());
183               } else {
184                  sb.append("TRUE");
185               }
186               return;
187            case 's':
188               if (o == null) {
189                  sb.append("null");
190                  return;
191               }
192               sb.append(o.toString());
193               return;
194            case 'S':
195               if (o == null) {
196                  sb.append("NULL");
197                  return;
198               }
199               sb.append(o.toString().toUpperCase());
200               return;
201            case 'd':
202               if (o == null) {
203                  sb.append("null");
204                  return;
205               }
206               if (o instanceof Number o2) {
207                  if (dl) {
208                     if (o instanceof Integer || o instanceof Long || o instanceof Byte || o instanceof Short) {
209                        sb.append(o);
210                     } else {
211                        // For other Number types (BigDecimal, BigInteger, etc.), convert to long
212                        sb.append(o2.longValue());
213                     }
214                     return;
215                  }
216                  // For non-default locales, use String.format to ensure printf-style consistency
217                  sb.append(sf(l, "%d", o));
218                  return;
219               }
220               break;
221            case 'x':
222               if (o == null) {
223                  sb.append("null");
224                  return;
225               }
226               if (o instanceof Integer o2) {
227                  sb.append(Integer.toHexString(o2));
228                  return;
229               } else if (o instanceof Long o2) {
230                  sb.append(Long.toHexString(o2));
231                  return;
232               }
233               break;
234            case 'X':
235               if (o == null) {
236                  sb.append("NULL");
237                  return;
238               }
239               if (o instanceof Integer o2) {
240                  sb.append(Integer.toHexString(o2).toUpperCase());
241                  return;
242               } else if (o instanceof Long o2) {
243                  sb.append(Long.toHexString(o2).toUpperCase());
244                  return;
245               }
246               break;
247            case 'o':
248               if (o == null) {
249                  sb.append("null");
250                  return;
251               }
252               if (o instanceof Integer o2) {
253                  sb.append(Integer.toOctalString(o2));
254                  return;
255               } else if (o instanceof Long o2) {
256                  sb.append(Long.toOctalString(o2));
257                  return;
258               }
259               break;
260            case 'c':
261               if (o == null) {
262                  sb.append("null");
263                  return;
264               }
265               if (o instanceof Character) {
266                  sb.append(o);
267                  return;
268               } else if (o instanceof Integer o2) {
269                  sb.append((char)o2.intValue());
270                  return;
271               }
272               break;
273            case 'C':
274               if (o == null) {
275                  sb.append("NULL");
276                  return;
277               }
278               if (o instanceof Character o2) {
279                  sb.append(Character.toUpperCase(o2));
280                  return;
281               } else if (o instanceof Integer o2) {
282                  sb.append(Character.toUpperCase((char)o2.intValue()));
283                  return;
284               }
285               break;
286            case 'f':
287               if (o == null) {
288                  sb.append("null");
289                  return;
290               }
291               // Always use String.format() to match exact behavior (precision, etc.)
292               if (o instanceof Number) {
293                  sb.append(sf(l, "%f", o));
294                  return;
295               }
296               break;
297            default:
298               break;
299         }
300
301         // Fallback to String.format for any other simple format
302         sb.append(sf(l, content, o));
303      }
304   }
305
306   /**
307    * Base class for format tokens.
308    */
309   private abstract static class Token {
310
311      /**
312       * Appends the formatted content to the StringBuilder.
313       *
314       * @param sb The StringBuilder to append to.
315       * @param args The arguments array.
316       * @param locale The locale for formatting (can be null for default).
317       */
318      abstract void append(StringBuilder sb, Object[] args, Locale locale);
319   }
320
321   private static final CacheMode CACHE_MODE = env("juneau.StringFormat.caching", CacheMode.FULL);
322
323   private static final Cache<String,StringFormat> CACHE = Cache.of(String.class, StringFormat.class).maxSize(1000).cacheMode(CACHE_MODE).build();
324
325   private static final AsciiSet PRINTF_CONVERSION_CHARS = AsciiSet.of("bBhHsScCdoxXeEfgGaAtTn%");
326
327   private static final AsciiSet PRINTF_FORMAT_CHARS = AsciiSet.of("-+ 0(#.*$");
328
329   /**
330    * Formats a pattern string with the given arguments using printf-style semantics.
331    *
332    * <p>
333    * <c>{</c> and <c>'</c> are literal text, matching {@link java.util.Formatter} semantics.
334    * If no arguments are passed in, the pattern is returned as-is.
335    *
336    * @param pattern The format pattern.
337    * @param args The arguments to format.
338    * @return The formatted string.
339    * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
340    */
341   public static String formatPrintf(String pattern, Object...args) {
342      if (args.length == 0)
343         return pattern;
344      return ofPrintf(pattern).format(args);
345   }
346
347   /**
348    * Formats a pattern string with the given arguments using printf-style semantics and the specified locale.
349    *
350    * @param pattern The format pattern.
351    * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
352    * @param args The arguments to format.
353    * @return The formatted string.
354    * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
355    */
356   public static String formatPrintf(String pattern, Locale locale, Object...args) {
357      if (args.length == 0)
358         return pattern;
359      return ofPrintf(pattern).format(locale, args);
360   }
361
362   /**
363    * Returns a cached printf StringFormat instance for the given pattern.
364    *
365    * <p>
366    * This method uses a thread-safe cache to avoid recreating StringFormat instances for the same pattern.
367    * The cache is limited to 1000 entries. <c>{</c> and <c>'</c> are treated as literal text (genuine
368    * {@link java.util.Formatter} semantics); only printf-style placeholders are recognized.
369    *
370    * @param pattern The format pattern.
371    * @return A cached or new StringFormat instance.
372    * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
373    */
374   public static StringFormat ofPrintf(String pattern) {
375      reqnn("pattern", pattern);
376      return CACHE.get(pattern, () -> new StringFormat(pattern));
377   }
378
379   private static void lit(List<Token> tokens, String pattern, int start) {
380      if (start == pattern.length())
381         return;
382      tokens.add(new LiteralToken(pattern.substring(start)));
383   }
384
385   private static void lit(List<Token> tokens, String pattern, int start, int end) {
386      if (start == end)
387         return;
388      tokens.add(new LiteralToken(pattern.substring(start, end)));
389   }
390
391   /**
392    * Parses the pattern into a list of tokens.
393    */
394   @SuppressWarnings({
395      "java:S125", // S125: state-machine/docs comments
396      "java:S1871" // else-branch intentionally duplicates known-conversion branch; semantics differ (valid vs unknown format char)
397   })
398   private static List<Token> parseTokens(String pattern) {
399      var tokens = new ArrayList<Token>();
400      var length = pattern.length();
401      var i = 0;
402      var sequentialIndex = 0; // 0-based index for sequential placeholders
403
404      // Possible String.format variable formats:
405      // %[argument_index$][flags][width][.precision]conversion
406      // %[argument_index$][flags][width]conversion
407      // %[flags][width]conversion
408
409      // S1 - In literal, looking for %
410      // S2 - Found %, looking for conversion char or t or T
411      var state = S1;
412
413      var mark = 0;
414      while (i < length) {
415         var ch = pattern.charAt(i++);
416
417         if (state == S1) {
418            if (ch == '%') {
419               lit(tokens, pattern, mark, i - 1);
420               state = S2;
421               mark = i;
422            }
423         } else /* if (state == S2) */ {
424            if (ch == '%') {
425               tokens.add(new LiteralToken("%"));
426               state = S1;
427               mark = i;
428            } else if (ch == 'n') {
429               // %n is special - it doesn't consume an argument, so handle it as a literal token
430               tokens.add(new LiteralToken(System.lineSeparator()));
431               state = S1;
432               mark = i;
433            } else if (ch == 't' || ch == 'T') {
434               // Do nothing.  Part of 2-character time conversion.
435            } else if (PRINTF_CONVERSION_CHARS.contains(ch)) {
436               StringFormatToken.create(tokens, pattern, mark, i, sequentialIndex++);
437               state = S1;
438               mark = i;
439            } else if (PRINTF_FORMAT_CHARS.contains(ch) || Character.isDigit(ch)) {
440               // Do nothing.
441            } else {
442               // Unknown character - could be invalid conversion or end of format
443               // Create StringFormatToken and let String.format() validate it
444               // This allows String.format() to throw IllegalFormatException for invalid conversions like %F
445               // printfStart is position after '%', so substring from printfStart-1 (the '%') to i (after the char)
446               StringFormatToken.create(tokens, pattern, mark, i, sequentialIndex++);
447               state = S1;
448               mark = i;
449            }
450         }
451      }
452
453      // Process remaining content based on final state
454      if (state == S1) {
455         lit(tokens, pattern, mark);
456      } else /* if (state == S2) */ {
457         // Dangling '%' without conversion - throw exception to match String.format() behavior
458         // UnknownFormatConversionException constructor takes just the conversion character
459         throw new UnknownFormatConversionException("%");
460      }
461
462      return tokens;
463   }
464
465
466   @SuppressWarnings({
467      "java:S3398" // sf() is a generic String.format wrapper; keeping it at outer-class scope alongside other utility methods is intentional
468   })
469   private static String sf(Locale l, String s, Object o) {
470      return String.format(l, s, a(o));
471   }
472
473   private final String pattern;
474
475   private final Token[] tokens;
476
477   /**
478    * Creates a new printf StringFormat instance.
479    *
480    * <p>
481    * <c>{</c> and <c>'</c> are treated as literal text (genuine {@link java.util.Formatter} semantics); only
482    * printf-style placeholders are recognized.
483    *
484    * @param pattern The format pattern.
485    * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
486    */
487   public StringFormat(String pattern) {
488      this.pattern = reqnn("pattern", pattern);
489      this.tokens = parseTokens(pattern).toArray(Token[]::new);
490   }
491
492   @Override
493   public boolean equals(Object o) {
494      return o instanceof StringFormat o2 && eq(this, o2, (x, y) -> eq(x.pattern, y.pattern));
495   }
496
497   /**
498    * Formats the pattern with the given arguments using the specified locale.
499    *
500    * <p>
501    * The locale affects locale-specific printf number formatting (decimal separators, etc.).
502    *
503    * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
504    * @param args The arguments to format.
505    * @return The formatted string.
506    * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
507    */
508   public String format(Locale locale, Object...args) {
509      var sb = new StringBuilder(pattern.length() + 64);
510      for (var token : tokens) {
511         token.append(sb, args, locale);
512      }
513      return sb.toString();
514   }
515
516   /**
517    * Formats the pattern with the given arguments using the default locale.
518    *
519    * @param args The arguments to format.
520    * @return The formatted string.
521    * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
522    */
523   public String format(Object...args) {
524      return format(Locale.getDefault(), args);
525   }
526
527   @Override
528   public int hashCode() {
529      return pattern.hashCode();
530   }
531
532   /**
533    * Returns a debug representation of the parsed pattern showing the token structure.
534    *
535    * <p>
536    * This method is useful for debugging and understanding how a pattern was parsed.
537    * It returns a string showing each token in the format:
538    * <ul>
539    *   <li><b>Literal tokens:</b> <js>"[L:text]"</js> - Literal text</li>
540    *   <li><b>StringFormat tokens (simple):</b> <js>"[S:s0:%s]"</js> - Simple printf placeholder (format='s', index=0, content)</li>
541    *   <li><b>StringFormat tokens (complex):</b> <js>"[S:z0:%.2f]"</js> - Complex printf placeholder (format='z', index=0, content)</li>
542    * </ul>
543    *
544    * <h5 class='section'>Token Format:</h5>
545    * <ul>
546    *   <li><b>L</b> = Literal token</li>
547    *   <li><b>S</b> = StringFormat (printf) token</li>
548    *   <li><b>Format character:</b> 's' = simple, 'z' = complex printf</li>
549    *   <li><b>Index:</b> 0-based argument index</li>
550    *   <li><b>Content:</b> The format string content (for complex tokens)</li>
551    * </ul>
552    *
553    * <h5 class='section'>Examples:</h5>
554    * <p class='bjava'>
555    *    StringFormat <jv>fmt</jv> = StringFormat.<jsm>ofPrintf</jsm>(<js>"Hello %s, you have %d items"</js>);
556    *    <jv>fmt</jv>.<jsm>toPattern</jsm>();
557    *    <jc>// Returns: "[L:Hello ][S:s0:%s][L:, you have ][S:d1:%d][L: items]"</jc>
558    * </p>
559    *
560    * @return A debug string showing the parsed token structure.
561    */
562   public String toPattern() {
563      return Arrays.stream(tokens).map(Object::toString).collect(Collectors.joining());
564   }
565
566   @Override
567   public String toString() {
568      return pattern;
569   }
570}