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}