You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2025/11/28 12:37:06 UTC

(juneau) 01/03: org.apache.juneau.common.reflect API improvements

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit 7edf985bce9b3d2fc936b19c90434e46052c1a79
Author: James Bognar <ja...@salesforce.com>
AuthorDate: Fri Nov 28 07:32:27 2025 -0500

    org.apache.juneau.common.reflect API improvements
---
 TODO-StringUtils.md                                |   2 +-
 .../apache/juneau/common/collections/Cache.java    | 145 +++-
 .../apache/juneau/common/collections/Cache2.java   |  90 +-
 .../apache/juneau/common/collections/Cache3.java   |  90 +-
 .../apache/juneau/common/collections/Cache4.java   |  90 +-
 .../apache/juneau/common/collections/Cache5.java   |  90 +-
 .../apache/juneau/common/reflect/PackageInfo.java  |   3 +-
 .../apache/juneau/common/utils/FormatString.java   | 724 ++++++++++++++++
 .../apache/juneau/common/utils/StringFormat.java   | 724 ++++++++++++++++
 .../apache/juneau/common/utils/StringUtils.java    | 371 +++++----
 .../java/org/apache/juneau/common/utils/Utils.java |  53 ++
 .../juneau/common/utils/FormatString_Test.java     | 416 ++++++++++
 .../juneau/common/utils/StringFormat_Test.java     | 416 ++++++++++
 .../juneau/common/utils/StringUtils_Test.java      | 909 +++++++++++++--------
 .../org/apache/juneau/common/utils/Utils_Test.java |  51 ++
 15 files changed, 3583 insertions(+), 591 deletions(-)

diff --git a/TODO-StringUtils.md b/TODO-StringUtils.md
index 82f8861fcf..40ba02dc58 100644
--- a/TODO-StringUtils.md
+++ b/TODO-StringUtils.md
@@ -220,7 +220,7 @@ The `StringUtils` class currently has 225+ public static methods covering:
 
 ## Existing Methods to Consider Enhancing
 
-- `format(String pattern, Object...args)` - Could be enhanced with more formatting options
+- ✅ `format(String pattern, Object...args)` - Could be enhanced with more formatting options
 - `parseNumber(String s, Class<? extends Number> type)` - Could add more number types
 - `getDuration(String s)` - Could support more duration formats
 - `replaceVars(String s, Map<String,Object> m)` - Could add more variable syntax options
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache.java
index a417db4489..184f11df93 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache.java
@@ -153,7 +153,10 @@ import org.apache.juneau.common.function.*;
 public class Cache<K,V> {
 
 	// Internal map with Tuple1 keys for content-based equality (especially for arrays)
+	// If threadLocal is true, this is null and threadLocalMap is used instead
 	private final Map<Tuple1<K>,V> map;
+	private final ThreadLocal<Map<Tuple1<K>,V>> threadLocalMap;
+	private final boolean isThreadLocal;
 
 	/**
 	 * Cache of Tuple1 wrapper objects to minimize object creation on repeated get/put calls.
@@ -161,8 +164,24 @@ public class Cache<K,V> {
 	 * <p>
 	 * Uses WeakHashMap so wrappers can be GC'd when keys are no longer referenced.
 	 * This provides a significant performance improvement for caches with repeated key access.
+	 * If threadLocal is true, this is null and threadLocalWrapperCache is used instead.
 	 */
 	private final Map<K,Tuple1<K>> wrapperCache;
+	private final ThreadLocal<Map<K,Tuple1<K>>> threadLocalWrapperCache;
+
+	/**
+	 * Gets the map for the current thread.
+	 *
+	 * @return The map for the current thread.
+	 */
+	private Map<Tuple1<K>,V> getMap() { return isThreadLocal ? threadLocalMap.get() : map; }
+
+	/**
+	 * Gets the wrapper cache for the current thread.
+	 *
+	 * @return The wrapper cache for the current thread.
+	 */
+	private Map<K,Tuple1<K>> getWrapperCache() { return isThreadLocal ? threadLocalWrapperCache.get() : wrapperCache; }
 
 	/**
 	 * Gets or creates a Tuple1 wrapper for the given key.
@@ -175,7 +194,7 @@ public class Cache<K,V> {
 	 * @return A cached or new Tuple1 wrapper for the key.
 	 */
 	private Tuple1<K> wrap(K key) {
-		return wrapperCache.computeIfAbsent(key, k -> Tuple1.of(k));
+		return getWrapperCache().computeIfAbsent(key, k -> Tuple1.of(k));
 	}
 
 	/**
@@ -211,6 +230,7 @@ public class Cache<K,V> {
 		int maxSize;
 		String id;
 		boolean logOnExit;
+		boolean threadLocal;
 		Function<K,V> supplier;
 
 		Builder() {
@@ -266,6 +286,68 @@ public class Cache<K,V> {
 			return this;
 		}
 
+		/**
+		 * Sets the caching mode to {@link CacheMode#WEAK WEAK}.
+		 *
+		 * <p>
+		 * This is a shortcut for calling <c>cacheMode(CacheMode.WEAK)</c>.
+		 *
+		 * <p>
+		 * Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
+		 * garbage collected when keys are no longer strongly referenced elsewhere.
+		 *
+		 * <h5 class='section'>Example:</h5>
+		 * <p class='bjava'>
+		 * 	<jc>// Weak caching for Class metadata</jc>
+		 * 	Cache&lt;Class&lt;?&gt;,ClassMeta&gt; <jv>cache</jv> = Cache.<jsm>of</jsm>(Class.<jk>class</jk>, ClassMeta.<jk>class</jk>)
+		 * 		.<jsm>weak</jsm>()
+		 * 		.build();
+		 * </p>
+		 *
+		 * @return This object for method chaining.
+		 * @see #cacheMode(CacheMode)
+		 */
+		public Builder<K,V> weak() {
+			return cacheMode(WEAK);
+		}
+
+		/**
+		 * Enables thread-local caching.
+		 *
+		 * <p>
+		 * When enabled, each thread gets its own separate cache instance. This is useful for
+		 * thread-unsafe objects like {@link java.text.MessageFormat} that need to be cached per thread.
+		 *
+		 * <p>
+		 * This is a shortcut for wrapping a cache in a {@link ThreadLocal}, but provides a cleaner API.
+		 *
+		 * <h5 class='section'>Example:</h5>
+		 * <p class='bjava'>
+		 * 	<jc>// Thread-local cache for MessageFormat instances</jc>
+		 * 	Cache&lt;String,MessageFormat&gt; <jv>cache</jv> = Cache
+		 * 		.<jsm>of</jsm>(String.<jk>class</jk>, MessageFormat.<jk>class</jk>)
+		 * 		.maxSize(100)
+		 * 		.<jsm>threadLocal</jsm>()
+		 * 		.build();
+		 * </p>
+		 *
+		 * <p>
+		 * This is equivalent to:
+		 * <p class='bjava'>
+		 * 	ThreadLocal&lt;Cache&lt;String,MessageFormat&gt;&gt; <jv>cache</jv> =
+		 * 		ThreadLocal.<jsm>withInitial</jsm>(() -&gt; Cache
+		 * 			.<jsm>of</jsm>(String.<jk>class</jk>, MessageFormat.<jk>class</jk>)
+		 * 			.maxSize(100)
+		 * 			.build());
+		 * </p>
+		 *
+		 * @return This object for method chaining.
+		 */
+		public Builder<K,V> threadLocal() {
+			threadLocal = true;
+			return this;
+		}
+
 		/**
 		 * Enables logging of cache statistics when the JVM exits.
 		 *
@@ -424,12 +506,30 @@ public class Cache<K,V> {
 		this.maxSize = builder.maxSize;
 		this.disableCaching = builder.cacheMode == NONE;
 		this.supplier = builder.supplier != null ? builder.supplier : (K) -> null;
-		if (builder.cacheMode == WEAK) {
-			this.map = synchronizedMap(new WeakHashMap<>());
-			this.wrapperCache = synchronizedMap(new WeakHashMap<>());
+		this.isThreadLocal = builder.threadLocal;
+
+		if (isThreadLocal) {
+			// Thread-local mode: each thread gets its own map
+			if (builder.cacheMode == WEAK) {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> synchronizedMap(new WeakHashMap<>()));
+				this.threadLocalWrapperCache = ThreadLocal.withInitial(() -> synchronizedMap(new WeakHashMap<>()));
+			} else {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> new ConcurrentHashMap<>());
+				this.threadLocalWrapperCache = ThreadLocal.withInitial(() -> synchronizedMap(new WeakHashMap<>()));
+			}
+			this.map = null;
+			this.wrapperCache = null;
 		} else {
-			this.map = new ConcurrentHashMap<>();
-			this.wrapperCache = synchronizedMap(new WeakHashMap<>());
+			// Normal mode: shared map across all threads
+			if (builder.cacheMode == WEAK) {
+				this.map = synchronizedMap(new WeakHashMap<>());
+				this.wrapperCache = synchronizedMap(new WeakHashMap<>());
+			} else {
+				this.map = new ConcurrentHashMap<>();
+				this.wrapperCache = synchronizedMap(new WeakHashMap<>());
+			}
+			this.threadLocalMap = null;
+			this.threadLocalWrapperCache = null;
 		}
 		if (builder.logOnExit) {
 			shutdownMessage(() -> builder.id + ":  hits=" + cacheHits.get() + ", misses: " + size());
@@ -505,16 +605,17 @@ public class Cache<K,V> {
 		assertArgNotNull("supplier", supplier);
 		if (disableCaching)
 			return supplier.get();
+		var m = getMap();
 		Tuple1<K> wrapped = wrap(key);
-		V v = map.get(wrapped);
+		V v = m.get(wrapped);
 		if (v == null) {
 			if (size() > maxSize)
 				clear();
 			v = supplier.get();
 			if (v == null)
-				map.remove(wrapped);
+				m.remove(wrapped);
 			else
-				map.putIfAbsent(wrapped, v);
+				m.putIfAbsent(wrapped, v);
 		} else {
 			cacheHits.incrementAndGet();
 		}
@@ -529,13 +630,14 @@ public class Cache<K,V> {
 	 * @return The previous value associated with the key, or <jk>null</jk> if there was no mapping.
 	 */
 	public V put(K key, V value) {
+		var m = getMap();
 		if (value == null) {
 			Tuple1<K> wrapped = wrap(key);
-			V result = map.remove(wrapped);
-			wrapperCache.remove(key); // Clean up wrapper cache
+			V result = m.remove(wrapped);
+			getWrapperCache().remove(key); // Clean up wrapper cache
 			return result;
 		}
-		return map.put(wrap(key), value);
+		return m.put(wrap(key), value);
 	}
 
 	/**
@@ -572,7 +674,7 @@ public class Cache<K,V> {
 	 * @return The number of cached entries.
 	 */
 	public int size() {
-		return map.size();
+		return getMap().size();
 	}
 
 	/**
@@ -580,7 +682,7 @@ public class Cache<K,V> {
 	 *
 	 * @return <jk>true</jk> if the cache is empty.
 	 */
-	public boolean isEmpty() { return map.isEmpty(); }
+	public boolean isEmpty() { return getMap().isEmpty(); }
 
 	/**
 	 * Returns <jk>true</jk> if the cache contains a mapping for the specified key.
@@ -589,7 +691,7 @@ public class Cache<K,V> {
 	 * @return <jk>true</jk> if the cache contains the key.
 	 */
 	public boolean containsKey(K key) {
-		return map.containsKey(wrap(key));
+		return getMap().containsKey(wrap(key));
 	}
 
 	/**
@@ -602,7 +704,7 @@ public class Cache<K,V> {
 		// ConcurrentHashMap doesn't allow null values, so null can never be in the cache
 		if (value == null)
 			return false;
-		return map.containsValue(value);
+		return getMap().containsValue(value);
 	}
 
 	/**
@@ -612,9 +714,10 @@ public class Cache<K,V> {
 	 * @return The previous value associated with the key, or <jk>null</jk> if there was no mapping.
 	 */
 	public V remove(K key) {
-		Tuple1<K> wrapped = wrap(key);
-		V result = map.remove(wrapped);
-		wrapperCache.remove(key); // Clean up wrapper cache
+		var m = getMap();
+		var wrapped = wrap(key);
+		V result = m.remove(wrapped);
+		getWrapperCache().remove(key); // Clean up wrapper cache
 		return result;
 	}
 
@@ -622,7 +725,7 @@ public class Cache<K,V> {
 	 * Removes all entries from the cache.
 	 */
 	public void clear() {
-		map.clear();
-		wrapperCache.clear(); // Clean up wrapper cache
+		getMap().clear();
+		getWrapperCache().clear(); // Clean up wrapper cache
 	}
 }
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache2.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache2.java
index da1f85619f..57ec837a16 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache2.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache2.java
@@ -137,7 +137,10 @@ import org.apache.juneau.common.function.*;
 public class Cache2<K1,K2,V> {
 
 	// Internal map with Tuple2 keys for content-based equality (especially for arrays)
+	// If threadLocal is true, this is null and threadLocalMap is used instead
 	private final java.util.Map<Tuple2<K1,K2>,V> map;
+	private final ThreadLocal<Map<Tuple2<K1,K2>,V>> threadLocalMap;
+	private final boolean isThreadLocal;
 
 	/**
 	 * Builder for creating configured {@link Cache2} instances.
@@ -165,6 +168,7 @@ public class Cache2<K1,K2,V> {
 		int maxSize;
 		String id;
 		boolean logOnExit;
+		boolean threadLocal;
 		Function2<K1,K2,V> supplier;
 
 		Builder() {
@@ -202,6 +206,41 @@ public class Cache2<K1,K2,V> {
 			return this;
 		}
 
+		/**
+		 * Sets the caching mode to {@link CacheMode#WEAK WEAK}.
+		 *
+		 * <p>
+		 * This is a shortcut for calling <c>cacheMode(CacheMode.WEAK)</c>.
+		 *
+		 * <p>
+		 * Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
+		 * garbage collected when keys are no longer strongly referenced elsewhere.
+		 *
+		 * @return This object for method chaining.
+		 * @see #cacheMode(CacheMode)
+		 */
+		public Builder<K1,K2,V> weak() {
+			return cacheMode(WEAK);
+		}
+
+		/**
+		 * Enables thread-local caching.
+		 *
+		 * <p>
+		 * When enabled, each thread gets its own separate cache instance. This is useful for
+		 * thread-unsafe objects that need to be cached per thread.
+		 *
+		 * <p>
+		 * This is a shortcut for wrapping a cache in a {@link ThreadLocal}, but provides a cleaner API.
+		 *
+		 * @return This object for method chaining.
+		 * @see Cache.Builder#threadLocal()
+		 */
+		public Builder<K1,K2,V> threadLocal() {
+			threadLocal = true;
+			return this;
+		}
+
 		/**
 		 * Enables logging of cache statistics when the JVM exits.
 		 *
@@ -365,16 +404,37 @@ public class Cache2<K1,K2,V> {
 		this.maxSize = builder.maxSize;
 		this.cacheMode = builder.cacheMode;
 		this.supplier = builder.supplier;
-		if (builder.cacheMode == WEAK) {
-			this.map = Collections.synchronizedMap(new WeakHashMap<>());
+		this.isThreadLocal = builder.threadLocal;
+
+		if (isThreadLocal) {
+			// Thread-local mode: each thread gets its own map
+			if (builder.cacheMode == WEAK) {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> Collections.synchronizedMap(new WeakHashMap<>()));
+			} else {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> new ConcurrentHashMap<>());
+			}
+			this.map = null;
 		} else {
-			this.map = new ConcurrentHashMap<>();
+			// Normal mode: shared map across all threads
+			if (builder.cacheMode == WEAK) {
+				this.map = Collections.synchronizedMap(new WeakHashMap<>());
+			} else {
+				this.map = new ConcurrentHashMap<>();
+			}
+			this.threadLocalMap = null;
 		}
 		if (builder.logOnExit) {
 			shutdownMessage(() -> builder.id + ":  hits=" + cacheHits.get() + ", misses: " + size());
 		}
 	}
 
+	/**
+	 * Gets the map for the current thread.
+	 *
+	 * @return The map for the current thread.
+	 */
+	private Map<Tuple2<K1,K2>,V> getMap() { return isThreadLocal ? threadLocalMap.get() : map; }
+
 	/**
 	 * Retrieves a cached value by key pair using the default supplier.
 	 *
@@ -446,16 +506,17 @@ public class Cache2<K1,K2,V> {
 		assertArgNotNull("supplier", supplier);
 		if (cacheMode == NONE)
 			return supplier.get();
+		var m = getMap();
 		var wrapped = Tuple2.of(key1, key2);
-		V v = map.get(wrapped);
+		V v = m.get(wrapped);
 		if (v == null) {
 			if (size() > maxSize)
 				clear();
 			v = supplier.get();
 			if (v == null)
-				map.remove(wrapped);
+				m.remove(wrapped);
 			else
-				map.putIfAbsent(wrapped, v);
+				m.putIfAbsent(wrapped, v);
 		} else {
 			cacheHits.incrementAndGet();
 		}
@@ -471,9 +532,10 @@ public class Cache2<K1,K2,V> {
 	 * @return The previous value associated with the key pair, or <jk>null</jk> if there was no mapping.
 	 */
 	public V put(K1 key1, K2 key2, V value) {
+		var m = getMap();
 		if (value == null)
-			return map.remove(Tuple2.of(key1, key2));
-		return map.put(Tuple2.of(key1, key2), value);
+			return m.remove(Tuple2.of(key1, key2));
+		return m.put(Tuple2.of(key1, key2), value);
 	}
 
 	/**
@@ -484,7 +546,7 @@ public class Cache2<K1,K2,V> {
 	 * @return The previous value associated with the key pair, or <jk>null</jk> if there was no mapping.
 	 */
 	public V remove(K1 key1, K2 key2) {
-		return map.remove(Tuple2.of(key1, key2));
+		return getMap().remove(Tuple2.of(key1, key2));
 	}
 
 	/**
@@ -495,7 +557,7 @@ public class Cache2<K1,K2,V> {
 	 * @return <jk>true</jk> if the cache contains the key pair.
 	 */
 	public boolean containsKey(K1 key1, K2 key2) {
-		return map.containsKey(Tuple2.of(key1, key2));
+		return getMap().containsKey(Tuple2.of(key1, key2));
 	}
 
 	/**
@@ -508,7 +570,7 @@ public class Cache2<K1,K2,V> {
 		// ConcurrentHashMap doesn't allow null values, so null can never be in the cache
 		if (value == null)
 			return false;
-		return map.containsValue(value);
+		return getMap().containsValue(value);
 	}
 
 	/**
@@ -517,7 +579,7 @@ public class Cache2<K1,K2,V> {
 	 * @return The number of cached entries.
 	 */
 	public int size() {
-		return map.size();
+		return getMap().size();
 	}
 
 	/**
@@ -525,13 +587,13 @@ public class Cache2<K1,K2,V> {
 	 *
 	 * @return <jk>true</jk> if the cache is empty.
 	 */
-	public boolean isEmpty() { return map.isEmpty(); }
+	public boolean isEmpty() { return getMap().isEmpty(); }
 
 	/**
 	 * Removes all entries from the cache.
 	 */
 	public void clear() {
-		map.clear();
+		getMap().clear();
 	}
 
 	/**
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache3.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache3.java
index 468775c69a..70790724d9 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache3.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache3.java
@@ -81,7 +81,10 @@ import org.apache.juneau.common.function.*;
 public class Cache3<K1,K2,K3,V> {
 
 	// Internal map with Tuple3 keys for content-based equality (especially for arrays)
+	// If threadLocal is true, this is null and threadLocalMap is used instead
 	private final java.util.Map<Tuple3<K1,K2,K3>,V> map;
+	private final ThreadLocal<Map<Tuple3<K1,K2,K3>,V>> threadLocalMap;
+	private final boolean isThreadLocal;
 
 	/**
 	 * Builder for creating configured {@link Cache3} instances.
@@ -96,6 +99,7 @@ public class Cache3<K1,K2,K3,V> {
 		int maxSize;
 		String id;
 		boolean logOnExit;
+		boolean threadLocal;
 		Function3<K1,K2,K3,V> supplier;
 
 		Builder() {
@@ -133,6 +137,41 @@ public class Cache3<K1,K2,K3,V> {
 			return this;
 		}
 
+		/**
+		 * Sets the caching mode to {@link CacheMode#WEAK WEAK}.
+		 *
+		 * <p>
+		 * This is a shortcut for calling <c>cacheMode(CacheMode.WEAK)</c>.
+		 *
+		 * <p>
+		 * Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
+		 * garbage collected when keys are no longer strongly referenced elsewhere.
+		 *
+		 * @return This object for method chaining.
+		 * @see #cacheMode(CacheMode)
+		 */
+		public Builder<K1,K2,K3,V> weak() {
+			return cacheMode(WEAK);
+		}
+
+		/**
+		 * Enables thread-local caching.
+		 *
+		 * <p>
+		 * When enabled, each thread gets its own separate cache instance. This is useful for
+		 * thread-unsafe objects that need to be cached per thread.
+		 *
+		 * <p>
+		 * This is a shortcut for wrapping a cache in a {@link ThreadLocal}, but provides a cleaner API.
+		 *
+		 * @return This object for method chaining.
+		 * @see Cache.Builder#threadLocal()
+		 */
+		public Builder<K1,K2,K3,V> threadLocal() {
+			threadLocal = true;
+			return this;
+		}
+
 		/**
 		 * Enables logging of cache statistics when the JVM exits.
 		 *
@@ -233,16 +272,37 @@ public class Cache3<K1,K2,K3,V> {
 		this.maxSize = builder.maxSize;
 		this.cacheMode = builder.cacheMode;
 		this.supplier = builder.supplier;
-		if (builder.cacheMode == WEAK) {
-			this.map = Collections.synchronizedMap(new WeakHashMap<>());
+		this.isThreadLocal = builder.threadLocal;
+
+		if (isThreadLocal) {
+			// Thread-local mode: each thread gets its own map
+			if (builder.cacheMode == WEAK) {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> Collections.synchronizedMap(new WeakHashMap<>()));
+			} else {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> new ConcurrentHashMap<>());
+			}
+			this.map = null;
 		} else {
-			this.map = new ConcurrentHashMap<>();
+			// Normal mode: shared map across all threads
+			if (builder.cacheMode == WEAK) {
+				this.map = Collections.synchronizedMap(new WeakHashMap<>());
+			} else {
+				this.map = new ConcurrentHashMap<>();
+			}
+			this.threadLocalMap = null;
 		}
 		if (builder.logOnExit) {
 			shutdownMessage(() -> builder.id + ":  hits=" + cacheHits.get() + ", misses: " + size());
 		}
 	}
 
+	/**
+	 * Gets the map for the current thread.
+	 *
+	 * @return The map for the current thread.
+	 */
+	private Map<Tuple3<K1,K2,K3>,V> getMap() { return isThreadLocal ? threadLocalMap.get() : map; }
+
 	/**
 	 * Retrieves a cached value by key triplet using the default supplier.
 	 *
@@ -269,16 +329,17 @@ public class Cache3<K1,K2,K3,V> {
 		assertArgNotNull("supplier", supplier);
 		if (cacheMode == NONE)
 			return supplier.get();
+		Map<Tuple3<K1,K2,K3>,V> m = getMap();
 		var wrapped = Tuple3.of(key1, key2, key3);
-		V v = map.get(wrapped);
+		V v = m.get(wrapped);
 		if (v == null) {
 			if (size() > maxSize)
 				clear();
 			v = supplier.get();
 			if (v == null)
-				map.remove(wrapped);
+				m.remove(wrapped);
 			else
-				map.putIfAbsent(wrapped, v);
+				m.putIfAbsent(wrapped, v);
 		} else {
 			cacheHits.incrementAndGet();
 		}
@@ -295,9 +356,10 @@ public class Cache3<K1,K2,K3,V> {
 	 * @return The previous value associated with the key triplet, or <jk>null</jk> if there was no mapping.
 	 */
 	public V put(K1 key1, K2 key2, K3 key3, V value) {
+		java.util.Map<Tuple3<K1,K2,K3>,V> m = getMap();
 		if (value == null)
-			return map.remove(Tuple3.of(key1, key2, key3));
-		return map.put(Tuple3.of(key1, key2, key3), value);
+			return m.remove(Tuple3.of(key1, key2, key3));
+		return m.put(Tuple3.of(key1, key2, key3), value);
 	}
 
 	/**
@@ -309,7 +371,7 @@ public class Cache3<K1,K2,K3,V> {
 	 * @return The previous value associated with the key triplet, or <jk>null</jk> if there was no mapping.
 	 */
 	public V remove(K1 key1, K2 key2, K3 key3) {
-		return map.remove(Tuple3.of(key1, key2, key3));
+		return getMap().remove(Tuple3.of(key1, key2, key3));
 	}
 
 	/**
@@ -321,7 +383,7 @@ public class Cache3<K1,K2,K3,V> {
 	 * @return <jk>true</jk> if the cache contains the key triplet.
 	 */
 	public boolean containsKey(K1 key1, K2 key2, K3 key3) {
-		return map.containsKey(Tuple3.of(key1, key2, key3));
+		return getMap().containsKey(Tuple3.of(key1, key2, key3));
 	}
 
 	/**
@@ -334,7 +396,7 @@ public class Cache3<K1,K2,K3,V> {
 		// ConcurrentHashMap doesn't allow null values, so null can never be in the cache
 		if (value == null)
 			return false;
-		return map.containsValue(value);
+		return getMap().containsValue(value);
 	}
 
 	/**
@@ -343,7 +405,7 @@ public class Cache3<K1,K2,K3,V> {
 	 * @return The number of cached entries.
 	 */
 	public int size() {
-		return map.size();
+		return getMap().size();
 	}
 
 	/**
@@ -351,13 +413,13 @@ public class Cache3<K1,K2,K3,V> {
 	 *
 	 * @return <jk>true</jk> if the cache is empty.
 	 */
-	public boolean isEmpty() { return map.isEmpty(); }
+	public boolean isEmpty() { return getMap().isEmpty(); }
 
 	/**
 	 * Removes all entries from the cache.
 	 */
 	public void clear() {
-		map.clear();
+		getMap().clear();
 	}
 
 	/**
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache4.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache4.java
index 7161d0896b..ee00dcb86f 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache4.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache4.java
@@ -67,7 +67,10 @@ import org.apache.juneau.common.function.*;
 public class Cache4<K1,K2,K3,K4,V> {
 
 	// Internal map with Tuple4 keys for content-based equality (especially for arrays)
+	// If threadLocal is true, this is null and threadLocalMap is used instead
 	private final java.util.Map<Tuple4<K1,K2,K3,K4>,V> map;
+	private final ThreadLocal<java.util.Map<Tuple4<K1,K2,K3,K4>,V>> threadLocalMap;
+	private final boolean isThreadLocal;
 
 	/**
 	 * Builder for creating configured {@link Cache4} instances.
@@ -83,6 +86,7 @@ public class Cache4<K1,K2,K3,K4,V> {
 		int maxSize;
 		String id;
 		boolean logOnExit;
+		boolean threadLocal;
 		Function4<K1,K2,K3,K4,V> supplier;
 
 		Builder() {
@@ -120,6 +124,41 @@ public class Cache4<K1,K2,K3,K4,V> {
 			return this;
 		}
 
+		/**
+		 * Sets the caching mode to {@link CacheMode#WEAK WEAK}.
+		 *
+		 * <p>
+		 * This is a shortcut for calling <c>cacheMode(CacheMode.WEAK)</c>.
+		 *
+		 * <p>
+		 * Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
+		 * garbage collected when keys are no longer strongly referenced elsewhere.
+		 *
+		 * @return This object for method chaining.
+		 * @see #cacheMode(CacheMode)
+		 */
+		public Builder<K1,K2,K3,K4,V> weak() {
+			return cacheMode(WEAK);
+		}
+
+		/**
+		 * Enables thread-local caching.
+		 *
+		 * <p>
+		 * When enabled, each thread gets its own separate cache instance. This is useful for
+		 * thread-unsafe objects that need to be cached per thread.
+		 *
+		 * <p>
+		 * This is a shortcut for wrapping a cache in a {@link ThreadLocal}, but provides a cleaner API.
+		 *
+		 * @return This object for method chaining.
+		 * @see Cache.Builder#threadLocal()
+		 */
+		public Builder<K1,K2,K3,K4,V> threadLocal() {
+			threadLocal = true;
+			return this;
+		}
+
 		/**
 		 * Enables logging of cache statistics when the JVM exits.
 		 *
@@ -223,16 +262,37 @@ public class Cache4<K1,K2,K3,K4,V> {
 		this.maxSize = builder.maxSize;
 		this.cacheMode = builder.cacheMode;
 		this.supplier = builder.supplier;
-		if (builder.cacheMode == WEAK) {
-			this.map = Collections.synchronizedMap(new WeakHashMap<>());
+		this.isThreadLocal = builder.threadLocal;
+
+		if (isThreadLocal) {
+			// Thread-local mode: each thread gets its own map
+			if (builder.cacheMode == WEAK) {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> Collections.synchronizedMap(new WeakHashMap<>()));
+			} else {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> new ConcurrentHashMap<>());
+			}
+			this.map = null;
 		} else {
-			this.map = new ConcurrentHashMap<>();
+			// Normal mode: shared map across all threads
+			if (builder.cacheMode == WEAK) {
+				this.map = Collections.synchronizedMap(new WeakHashMap<>());
+			} else {
+				this.map = new ConcurrentHashMap<>();
+			}
+			this.threadLocalMap = null;
 		}
 		if (builder.logOnExit) {
 			shutdownMessage(() -> builder.id + ":  hits=" + cacheHits.get() + ", misses: " + size());
 		}
 	}
 
+	/**
+	 * Gets the map for the current thread.
+	 *
+	 * @return The map for the current thread.
+	 */
+	private Map<Tuple4<K1,K2,K3,K4>,V> getMap() { return isThreadLocal ? threadLocalMap.get() : map; }
+
 	/**
 	 * Retrieves a cached value by four-part key using the default supplier.
 	 *
@@ -263,16 +323,17 @@ public class Cache4<K1,K2,K3,K4,V> {
 		assertArgNotNull("supplier", supplier);
 		if (cacheMode == NONE)
 			return supplier.get();
+		var m = getMap();
 		var wrapped = Tuple4.of(key1, key2, key3, key4);
-		V v = map.get(wrapped);
+		V v = m.get(wrapped);
 		if (v == null) {
 			if (size() > maxSize)
 				clear();
 			v = supplier.get();
 			if (v == null)
-				map.remove(wrapped);
+				m.remove(wrapped);
 			else
-				map.putIfAbsent(wrapped, v);
+				m.putIfAbsent(wrapped, v);
 		} else {
 			cacheHits.incrementAndGet();
 		}
@@ -291,9 +352,10 @@ public class Cache4<K1,K2,K3,K4,V> {
 	 * 
 	 */
 	public V put(K1 key1, K2 key2, K3 key3, K4 key4, V value) {
+		var m = getMap();
 		if (value == null)
-			return map.remove(Tuple4.of(key1, key2, key3, key4));
-		return map.put(Tuple4.of(key1, key2, key3, key4), value);
+			return m.remove(Tuple4.of(key1, key2, key3, key4));
+		return m.put(Tuple4.of(key1, key2, key3, key4), value);
 	}
 
 	/**
@@ -307,7 +369,7 @@ public class Cache4<K1,K2,K3,K4,V> {
 	 * 
 	 */
 	public V remove(K1 key1, K2 key2, K3 key3, K4 key4) {
-		return map.remove(Tuple4.of(key1, key2, key3, key4));
+		return getMap().remove(Tuple4.of(key1, key2, key3, key4));
 	}
 
 	/**
@@ -320,7 +382,7 @@ public class Cache4<K1,K2,K3,K4,V> {
 	 * @return <jk>true</jk> if the cache contains the four-part key.
 	 */
 	public boolean containsKey(K1 key1, K2 key2, K3 key3, K4 key4) {
-		return map.containsKey(Tuple4.of(key1, key2, key3, key4));
+		return getMap().containsKey(Tuple4.of(key1, key2, key3, key4));
 	}
 
 	/**
@@ -333,7 +395,7 @@ public class Cache4<K1,K2,K3,K4,V> {
 		// ConcurrentHashMap doesn't allow null values, so null can never be in the cache
 		if (value == null)
 			return false;
-		return map.containsValue(value);
+		return getMap().containsValue(value);
 	}
 
 	/**
@@ -342,7 +404,7 @@ public class Cache4<K1,K2,K3,K4,V> {
 	 * @return The number of cached entries.
 	 */
 	public int size() {
-		return map.size();
+		return getMap().size();
 	}
 
 	/**
@@ -350,13 +412,13 @@ public class Cache4<K1,K2,K3,K4,V> {
 	 *
 	 * @return <jk>true</jk> if the cache is empty.
 	 */
-	public boolean isEmpty() { return map.isEmpty(); }
+	public boolean isEmpty() { return getMap().isEmpty(); }
 
 	/**
 	 * Removes all entries from the cache.
 	 */
 	public void clear() {
-		map.clear();
+		getMap().clear();
 	}
 
 	/**
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache5.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache5.java
index 5c04504991..befb2b10e6 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache5.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/collections/Cache5.java
@@ -69,7 +69,10 @@ import org.apache.juneau.common.function.*;
 public class Cache5<K1,K2,K3,K4,K5,V> {
 
 	// Internal map with Tuple5 keys for content-based equality (especially for arrays)
+	// If threadLocal is true, this is null and threadLocalMap is used instead
 	private final java.util.Map<Tuple5<K1,K2,K3,K4,K5>,V> map;
+	private final ThreadLocal<java.util.Map<Tuple5<K1,K2,K3,K4,K5>,V>> threadLocalMap;
+	private final boolean isThreadLocal;
 
 	/**
 	 * Builder for creating configured {@link Cache5} instances.
@@ -86,6 +89,7 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 		int maxSize;
 		String id;
 		boolean logOnExit;
+		boolean threadLocal;
 		Function5<K1,K2,K3,K4,K5,V> supplier;
 
 		Builder() {
@@ -123,6 +127,41 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 			return this;
 		}
 
+		/**
+		 * Sets the caching mode to {@link CacheMode#WEAK WEAK}.
+		 *
+		 * <p>
+		 * This is a shortcut for calling <c>cacheMode(CacheMode.WEAK)</c>.
+		 *
+		 * <p>
+		 * Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
+		 * garbage collected when keys are no longer strongly referenced elsewhere.
+		 *
+		 * @return This object for method chaining.
+		 * @see #cacheMode(CacheMode)
+		 */
+		public Builder<K1,K2,K3,K4,K5,V> weak() {
+			return cacheMode(WEAK);
+		}
+
+		/**
+		 * Enables thread-local caching.
+		 *
+		 * <p>
+		 * When enabled, each thread gets its own separate cache instance. This is useful for
+		 * thread-unsafe objects that need to be cached per thread.
+		 *
+		 * <p>
+		 * This is a shortcut for wrapping a cache in a {@link ThreadLocal}, but provides a cleaner API.
+		 *
+		 * @return This object for method chaining.
+		 * @see Cache.Builder#threadLocal()
+		 */
+		public Builder<K1,K2,K3,K4,K5,V> threadLocal() {
+			threadLocal = true;
+			return this;
+		}
+
 		/**
 		 * Enables logging of cache statistics when the JVM exits.
 		 *
@@ -229,16 +268,37 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 		this.maxSize = builder.maxSize;
 		this.cacheMode = builder.cacheMode;
 		this.supplier = builder.supplier;
-		if (builder.cacheMode == WEAK) {
-			this.map = Collections.synchronizedMap(new WeakHashMap<>());
+		this.isThreadLocal = builder.threadLocal;
+
+		if (isThreadLocal) {
+			// Thread-local mode: each thread gets its own map
+			if (builder.cacheMode == WEAK) {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> Collections.synchronizedMap(new WeakHashMap<>()));
+			} else {
+				this.threadLocalMap = ThreadLocal.withInitial(() -> new ConcurrentHashMap<>());
+			}
+			this.map = null;
 		} else {
-			this.map = new ConcurrentHashMap<>();
+			// Normal mode: shared map across all threads
+			if (builder.cacheMode == WEAK) {
+				this.map = Collections.synchronizedMap(new WeakHashMap<>());
+			} else {
+				this.map = new ConcurrentHashMap<>();
+			}
+			this.threadLocalMap = null;
 		}
 		if (builder.logOnExit) {
 			shutdownMessage(() -> builder.id + ":  hits=" + cacheHits.get() + ", misses: " + size());
 		}
 	}
 
+	/**
+	 * Gets the map for the current thread.
+	 *
+	 * @return The map for the current thread.
+	 */
+	private Map<Tuple5<K1,K2,K3,K4,K5>,V> getMap() { return isThreadLocal ? threadLocalMap.get() : map; }
+
 	/**
 	 * Retrieves a cached value by five-part key using the default supplier.
 	 *
@@ -271,16 +331,17 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 		assertArgNotNull("supplier", supplier);
 		if (cacheMode == NONE)
 			return supplier.get();
+		var m = getMap();
 		var wrapped = Tuple5.of(key1, key2, key3, key4, key5);
-		V v = map.get(wrapped);
+		V v = m.get(wrapped);
 		if (v == null) {
 			if (size() > maxSize)
 				clear();
 			v = supplier.get();
 			if (v == null)
-				map.remove(wrapped);
+				m.remove(wrapped);
 			else
-				map.putIfAbsent(wrapped, v);
+				m.putIfAbsent(wrapped, v);
 		} else {
 			cacheHits.incrementAndGet();
 		}
@@ -300,9 +361,10 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 	 * 
 	 */
 	public V put(K1 key1, K2 key2, K3 key3, K4 key4, K5 key5, V value) {
+		var m = getMap();
 		if (value == null)
-			return map.remove(Tuple5.of(key1, key2, key3, key4, key5));
-		return map.put(Tuple5.of(key1, key2, key3, key4, key5), value);
+			return m.remove(Tuple5.of(key1, key2, key3, key4, key5));
+		return m.put(Tuple5.of(key1, key2, key3, key4, key5), value);
 	}
 
 	/**
@@ -317,7 +379,7 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 	 * 
 	 */
 	public V remove(K1 key1, K2 key2, K3 key3, K4 key4, K5 key5) {
-		return map.remove(Tuple5.of(key1, key2, key3, key4, key5));
+		return getMap().remove(Tuple5.of(key1, key2, key3, key4, key5));
 	}
 
 	/**
@@ -331,7 +393,7 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 	 * @return <jk>true</jk> if the cache contains the five-part key.
 	 */
 	public boolean containsKey(K1 key1, K2 key2, K3 key3, K4 key4, K5 key5) {
-		return map.containsKey(Tuple5.of(key1, key2, key3, key4, key5));
+		return getMap().containsKey(Tuple5.of(key1, key2, key3, key4, key5));
 	}
 
 	/**
@@ -344,7 +406,7 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 		// ConcurrentHashMap doesn't allow null values, so null can never be in the cache
 		if (value == null)
 			return false;
-		return map.containsValue(value);
+		return getMap().containsValue(value);
 	}
 
 	/**
@@ -353,7 +415,7 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 	 * @return The number of cached entries.
 	 */
 	public int size() {
-		return map.size();
+		return getMap().size();
 	}
 
 	/**
@@ -361,13 +423,13 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
 	 *
 	 * @return <jk>true</jk> if the cache is empty.
 	 */
-	public boolean isEmpty() { return map.isEmpty(); }
+	public boolean isEmpty() { return getMap().isEmpty(); }
 
 	/**
 	 * Removes all entries from the cache.
 	 */
 	public void clear() {
-		map.clear();
+		getMap().clear();
 	}
 
 	/**
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/reflect/PackageInfo.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/reflect/PackageInfo.java
index 074bf97b07..2850a63067 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/reflect/PackageInfo.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/reflect/PackageInfo.java
@@ -122,7 +122,8 @@ public class PackageInfo implements Annotatable {
 	protected PackageInfo(Package inner) {
 		assertArgNotNull("inner", inner);
 		this.inner = inner;
-		this.annotations = memoize(() -> opt(inner).map(pkg -> stream(pkg.getAnnotations()).flatMap(a -> AnnotationUtils.streamRepeated(a)).map(a -> AnnotationInfo.of(this, a)).toList()).orElse(liste()));
+		this.annotations = memoize(
+			() -> opt(inner).map(pkg -> stream(pkg.getAnnotations()).flatMap(a -> AnnotationUtils.streamRepeated(a)).map(a -> AnnotationInfo.of(this, a)).toList()).orElse(liste()));
 	}
 
 	/**
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/FormatString.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/FormatString.java
new file mode 100644
index 0000000000..d3a2e9dc69
--- /dev/null
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/FormatString.java
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.common.utils;
+
+import static org.apache.juneau.common.utils.AssertionUtils.*;
+import static org.apache.juneau.common.utils.CollectionUtils.*;
+import static org.apache.juneau.common.utils.StateEnum.*;
+import static org.apache.juneau.common.utils.Utils.*;
+
+import java.text.*;
+import java.util.*;
+import java.util.stream.*;
+import java.util.MissingFormatArgumentException;
+
+import org.apache.juneau.common.collections.*;
+
+/**
+ * Unified string formatter supporting both MessageFormat-style and printf-style formatting in the same pattern.
+ *
+ * <p>
+ * This class provides a thread-safe, cacheable formatter that can handle mixed format styles in a single pattern.
+ * It supports both MessageFormat syntax (<js>"{0}"</js>, <js>"{1,number}"</js>) and printf syntax (<js>"%s"</js>, <js>"%d"</js>)
+ * within the same string.
+ *
+ * <h5 class='section'>Features:</h5>
+ * <ul>
+ *   <li><b>Dual Format Support:</b> Mix MessageFormat and printf-style placeholders in the same pattern</li>
+ *   <li><b>Thread-Safe:</b> Immutable class, safe for concurrent use</li>
+ *   <li><b>Cacheable:</b> Use {@link #of(String)} for cached instances</li>
+ *   <li><b>Argument Sharing:</b> Both format styles share the same argument array</li>
+ * </ul>
+ *
+ * <h5 class='section'>Format Style Detection:</h5>
+ * <p>
+ * The formatter automatically detects which style to use for each placeholder:
+ * <ul>
+ *   <li><b>MessageFormat style:</b> <js>"{0}"</js>, <js>"{1,number}"</js>, <js>"{2,date}"</js>, etc.</li>
+ *   <li><b>Printf style:</b> <js>"%s"</js>, <js>"%d"</js>, <js>"%.2f"</js>, <js>"%1$s"</js>, etc.</li>
+ * </ul>
+ *
+ * <h5 class='section'>Argument Mapping:</h5>
+ * <p>
+ * Arguments are processed in order of appearance:
+ * <ul>
+ *   <li><b>MessageFormat placeholders:</b> Use explicit indices (e.g., <js>"{0}"</js> uses <c>args[0]</c>)</li>
+ *   <li><b>Printf placeholders:</b> Use sequential indices starting after the highest MessageFormat index</li>
+ * </ul>
+ *
+ * <h5 class='section'>Examples:</h5>
+ * <p class='bjava'>
+ * 	<jc>// Mixed format styles</jc>
+ * 	FormatString <jv>fmt</jv> = FormatString.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>);
+ * 	String <jv>result</jv> = <jv>fmt</jv>.<jsm>format</jsm>(<js>"John"</js>, 5);
+ * 	<jc>// Returns: "Hello John, you have 5 items"</jc>
+ *
+ * 	<jc>// MessageFormat with explicit indices, printf with sequential</jc>
+ * 	FormatString <jv>fmt2</jv> = FormatString.<jsm>of</jsm>(<js>"User {0} has %s and {1} items"</js>);
+ * 	String <jv>result2</jv> = <jv>fmt2</jv>.<jsm>format</jsm>(<js>"Alice"</js>, 10, <js>"admin"</js>);
+ * 	<jc>// Returns: "User Alice has admin and 10 items"</jc>
+ * 	<jc>// {0} -> "Alice", {1} -> 10, %s -> "admin"</jc>
+ *
+ * 	<jc>// Printf with explicit indices</jc>
+ * 	FormatString <jv>fmt3</jv> = FormatString.<jsm>of</jsm>(<js>"%1$s loves %2$s, and {0} also loves %3$s"</js>);
+ * 	String <jv>result3</jv> = <jv>fmt3</jv>.<jsm>format</jsm>(<js>"Alice"</js>, <js>"Bob"</js>, <js>"Charlie"</js>);
+ * 	<jc>// Returns: "Alice loves Bob, and Alice also loves Charlie"</jc>
+ * </p>
+ *
+ * <h5 class='section'>Caching:</h5>
+ * <p>
+ * Use {@link #of(String)} to get cached instances. The cache is thread-safe and limited to 1000 entries.
+ * For uncached instances, use the constructor directly.
+ * </p>
+ *
+ * <h5 class='section'>Thread Safety:</h5>
+ * <p>
+ * This class is immutable and thread-safe. Multiple threads can safely use the same instance concurrently.
+ * </p>
+ *
+ * @see StringUtils#format(String, Object...)
+ * @see StringUtils#mformat(String, Object...)
+ */
+public final class FormatString {
+
+	private static final Cache<String,FormatString> CACHE = Cache.of(String.class, FormatString.class).maxSize(1000).weak().build();
+
+	private static final Cache2<Locale,String,MessageFormat> MESSAGE_FORMAT_CACHE = Cache2.of(Locale.class, String.class, MessageFormat.class).maxSize(100).threadLocal().weak()
+		.supplier((locale, content) -> new MessageFormat(content, locale)).build();
+
+	private static final Cache<Locale,NumberFormat> NUMBER_FORMAT_CACHE = Cache.of(Locale.class, NumberFormat.class).maxSize(50).threadLocal().weak().supplier(NumberFormat::getInstance).build();
+
+	private static final Cache<Locale,DateFormat> DATE_FORMAT_CACHE = Cache.of(Locale.class, DateFormat.class).maxSize(50).threadLocal().weak()
+		.supplier(locale -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale)).build();
+
+	private static final AsciiSet PRINTF_CONVERSION_CHARS = AsciiSet.of("bBhHsScCdoxXeEfgGaAtTn%");
+	private static final AsciiSet PRINTF_FORMAT_CHARS = AsciiSet.of("-+ 0(#.*$");
+	private static final AsciiSet FORMAT_CHARS = AsciiSet.of("%{'");
+
+	/**
+	 * Formats a pattern string with the given arguments using the default locale.
+	 *
+	 * <p>
+	 * This is a convenience method that creates a FormatString instance and formats it.
+	 * If the pattern contains no format specifiers (no '%', '{', or '''), the pattern is returned as-is.
+	 *
+	 * @param pattern The format pattern.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
+	 */
+	public static String format(String pattern, Object...args) {
+		if (!hasArgs(pattern))
+			return pattern;
+		return of(pattern).format(args);
+	}
+
+	/**
+	 * Formats a pattern string with the given arguments using the specified locale.
+	 *
+	 * <p>
+	 * This is a convenience method that creates a FormatString instance and formats it.
+	 * If the pattern contains no format specifiers (no '%', '{', or '''), the pattern is returned as-is.
+	 *
+	 * @param pattern The format pattern.
+	 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
+	 */
+	public static String format(String pattern, Locale locale, Object...args) {
+		if (!hasArgs(pattern))
+			return pattern;
+		return of(pattern).format(locale, args);
+	}
+
+	/**
+	 * Checks if a pattern contains any format specifiers.
+	 *
+	 * <p>
+	 * Returns <jk>true</jk> if the pattern contains any of the following characters:
+	 * <ul>
+	 *   <li><js>'%'</js> - Printf-style format specifier</li>
+	 *   <li><js>'{'</js> - MessageFormat-style placeholder</li>
+	 *   <li><js>'''</js> - MessageFormat quote</li>
+	 * </ul>
+	 *
+	 * <p>
+	 * Uses {@link AsciiSet} for efficient single-pass scanning of the string.
+	 *
+	 * @param pattern The pattern to check.
+	 * @return <jk>true</jk> if the pattern contains format specifiers, <jk>false</jk> otherwise.
+	 */
+	private static boolean hasArgs(String pattern) {
+		if (pattern == null || pattern.isEmpty())
+			return false;
+		for (var i = 0; i < pattern.length(); i++) {
+			if (FORMAT_CHARS.contains(pattern.charAt(i)))
+				return true;
+		}
+		return false;
+	}
+
+	/**
+	 * Base class for format tokens.
+	 */
+	private abstract static class Token {
+		/**
+		 * Appends the formatted content to the StringBuilder.
+		 *
+		 * @param sb The StringBuilder to append to.
+		 * @param args The arguments array.
+		 * @param locale The locale for formatting (can be null for default).
+		 */
+		abstract void append(StringBuilder sb, Object[] args, Locale locale);
+	}
+
+	/**
+	 * Literal text token.
+	 */
+	private static final class LiteralToken extends Token {
+		private final String text;
+
+		LiteralToken(String text) {
+			this.text = text;
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			sb.append(text);
+		}
+
+		@Override
+		public String toString() {
+			return "[L:" + text + "]";
+		}
+	}
+
+	/**
+	 * MessageFormat-style token (e.g., {0}, {1,number}).
+	 */
+	private static final class MessageFormatToken extends Token {
+		private final char format;
+		private final String content; // null for simple tokens, normalized pattern like "{0,number}" for complex tokens
+		private final int index; // 0-based index
+		private final String placeholder; // Original placeholder text like "{0}" or "{0,number}"
+
+		/**
+		 * @param content - The variable content such as "{}" or "{0}", or "{0,number}"
+		 * 	The content should have the curly-braces already removed so that we're only looking at the inner parts.
+		 * @param index - The zero-based index of the variable in the message (used for sequential {} placeholders).
+		 */
+		MessageFormatToken(String content, int index) {
+			if (content.isBlank()) {
+				this.content = null;
+				this.index = index;
+				this.format = 's';
+				this.placeholder = "{" + index + "}";
+			} else if (content.indexOf(',') == -1) {
+				this.content = null;
+				this.index = parseIndexMF(content);
+				this.format = 's';
+				this.placeholder = "{" + this.index + "}";
+			} else {
+				var tokens = content.split(",", 2);
+				this.index = parseIndexMF(tokens[0]);
+				this.content = "{0," + tokens[1] + "}";
+				this.format = 'o';
+				this.placeholder = "{" + this.index + "," + tokens[1] + "}";
+			}
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			// MessageFormat inserts the placeholder text if argument is missing
+			if (args == null || index >= args.length || index < 0) {
+				sb.append(placeholder);
+				return;
+			}
+			var o = args[index];
+			var l = locale == null ? Locale.getDefault() : locale;
+			switch (format) {
+				case 's':
+					if (o == null) {
+						sb.append("null");
+					} else if (o instanceof Number o2) {
+						sb.append(NUMBER_FORMAT_CACHE.get(l).format(o2));
+					} else if (o instanceof Date o2) {
+						sb.append(DATE_FORMAT_CACHE.get(l).format(o2));
+					} else {
+						sb.append(o.toString());
+					}
+					break;
+				default:
+					// Use Cache2 with Locale and content as separate keys to avoid string concatenation
+					var mf = MESSAGE_FORMAT_CACHE.get(l, content);
+					sb.append(mf.format(a(o)));
+					break;
+			}
+		}
+
+		@Override
+		public String toString() {
+			return "[M:" + format + index + (content == null ? "" : (':' + content)) + "]";
+		}
+	}
+
+	/**
+	 * Printf-style token (e.g., %s, %d, %.2f).
+	 */
+	private static final class StringFormatToken extends Token {
+		private final char format; // 's' = simple (handle directly), 'o' = other (use String.format)
+		private final String content; // The format string to pass to String.format (null for simple formats)
+		private final int index; // 0-based index
+
+		StringFormatToken(String content, int index) {
+			// content is everything after '%' (e.g., "s", "1$s", "d", ".2f", "1$.2f")
+			var $ = content.indexOf('$');
+			if ($ >= 0) {
+				index = parseIndexSF(content.substring(0, $)) - 1;
+				content = content.substring($ + 1);
+			}
+			this.format = content.length() == 1 ? content.charAt(content.length() - 1) : 'z';
+			this.index = index;
+			this.content = "%" + content;
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			// String.format() throws MissingFormatArgumentException when argument is missing
+			if (args == null || index >= args.length || index < 0) {
+				throw new MissingFormatArgumentException(content);
+			}
+			var o = args[index];
+			var l = locale == null ? Locale.getDefault() : locale;
+			var dl = locale == null || locale.equals(Locale.getDefault());
+			switch (format) {
+				case 'b':
+					// String.format() with %b converts:
+					// - null -> "false"
+					// - Boolean -> toString()
+					// - Any other non-null value -> "true"
+					if (o == null) {
+						sb.append("false");
+					} else if (o instanceof Boolean) {
+						sb.append(o.toString());
+					} else {
+						sb.append("true");
+					}
+					return;
+				case 'B':
+					// String.format() with %B converts:
+					// - null -> "FALSE"
+					// - Boolean -> toString().toUpperCase()
+					// - Any other non-null value -> "TRUE"
+					if (o == null) {
+						sb.append("FALSE");
+					} else if (o instanceof Boolean) {
+						sb.append(o.toString().toUpperCase());
+					} else {
+						sb.append("TRUE");
+					}
+					return;
+				case 's':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					sb.append(o.toString());
+					return;
+				case 'S':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					sb.append(o.toString().toUpperCase());
+					return;
+				case 'd':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Number o2) {
+						if (dl) {
+							if (o instanceof Integer || o instanceof Long || o instanceof Byte || o instanceof Short) {
+								sb.append(o);
+							} else {
+								// For other Number types (BigDecimal, BigInteger, etc.), convert to long
+								sb.append(o2.longValue());
+							}
+							return;
+						}
+						// For non-default locales, use String.format to ensure printf-style consistency
+						sb.append(sf(l, "%d", o));
+						return;
+					}
+					break;
+				case 'x':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toHexString(o2));
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toHexString(o2));
+						return;
+					}
+					break;
+				case 'X':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toHexString(o2).toUpperCase());
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toHexString(o2).toUpperCase());
+						return;
+					}
+					break;
+				case 'o':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toOctalString(o2));
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toOctalString(o2));
+						return;
+					}
+					break;
+				case 'c':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Character) {
+						sb.append(o);
+						return;
+					} else if (o instanceof Integer o2) {
+						sb.append((char)o2.intValue());
+						return;
+					}
+					break;
+				case 'C':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					if (o instanceof Character o2) {
+						sb.append(Character.toUpperCase(o2));
+						return;
+					} else if (o instanceof Integer o2) {
+						sb.append(Character.toUpperCase((char)o2.intValue()));
+						return;
+					}
+					break;
+				case 'f':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					// Always use String.format() to match exact behavior (precision, etc.)
+					if (o instanceof Number) {
+						sb.append(sf(l, "%f", o));
+						return;
+					}
+					break;
+				default:
+					break;
+			}
+
+			// Fallback to String.format for any other simple format
+			sb.append(sf(l, content, o));
+		}
+
+		@Override
+		public String toString() {
+			return "[S:" + format + index + ":" + content + "]";
+		}
+	}
+
+	private static String sf(Locale l, String s, Object o) {
+		return String.format(l, s, a(o));
+	}
+
+	private final String pattern;
+	private final Token[] tokens;
+
+	/**
+	 * Creates a new FormatString instance.
+	 *
+	 * @param pattern The format pattern. Can contain both MessageFormat and printf-style placeholders.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
+	 */
+	public FormatString(String pattern) {
+		this.pattern = assertArgNotNull("pattern", pattern);
+		this.tokens = parseTokens(pattern).toArray(Token[]::new);
+	}
+
+	/**
+	 * Returns a cached FormatString instance for the given pattern.
+	 *
+	 * <p>
+	 * This method uses a thread-safe cache to avoid recreating FormatString instances for the same pattern.
+	 * The cache is limited to 1000 entries.
+	 *
+	 * @param pattern The format pattern.
+	 * @return A cached or new FormatString instance.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
+	 */
+	public static FormatString of(String pattern) {
+		assertArgNotNull("pattern", pattern);
+		return CACHE.get(pattern, () -> new FormatString(pattern));
+	}
+
+	/**
+	 * Formats the pattern with the given arguments using the default locale.
+	 *
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
+	 */
+	public String format(Object...args) {
+		return format(Locale.getDefault(), args);
+	}
+
+	/**
+	 * Formats the pattern with the given arguments using the specified locale.
+	 *
+	 * <p>
+	 * The locale affects both MessageFormat and printf-style formatting:
+	 * <ul>
+	 *   <li><b>MessageFormat:</b> Locale-specific number, date, and time formatting</li>
+	 *   <li><b>Printf:</b> Locale-specific number formatting (decimal separators, etc.)</li>
+	 * </ul>
+	 *
+	 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
+	 */
+	public String format(Locale locale, Object...args) {
+		var sb = new StringBuilder(pattern.length() + 64);
+		for (var token : tokens) {
+			token.append(sb, args, locale);
+		}
+		return sb.toString();
+	}
+
+	/**
+	 * Parses the pattern into a list of tokens.
+	 */
+	private static List<Token> parseTokens(String pattern) {
+		var tokens = new ArrayList<Token>();
+		var length = pattern.length();
+		var i = 0;
+		var sequentialIndex = 0; // 0-based index for sequential placeholders
+
+		// Possible String.format variable formats:
+		// %[argument_index$][flags][width][.precision]conversion
+		// %[argument_index$][flags][width]conversion
+		// %[flags][width]conversion
+
+		// Possible MessageFormat variable formats:
+		// {}
+		// {#,formatType}
+		// {#,formatType,formatStyle}
+
+		// S1 - In literal, looking for %, {, or '
+		// S2 - Found %, looking for conversion char or t or T
+		// S3 - Found {, looking for }
+		// S4 - Found ', in quoted section (MessageFormat single quotes escape special chars), looking for '
+		var state = S1;
+
+		var nestedBracketDepth = 0;
+
+		var mark = 0;
+		while (i < length) {
+			var ch = pattern.charAt(i++);
+
+			if (state == S1) {
+				if (ch == '%') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S2;
+					mark = i;
+				} else if (ch == '{') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S3;
+					mark = i - 1;
+				} else if (ch == '\'') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S4;
+					mark = i;
+				}
+			} else if (state == S2) {
+				if (ch == '%') {
+					tokens.add(new LiteralToken("%"));
+					state = S1;
+					mark = i;
+				} else if (ch == 't' || ch == 'T') {
+					// Do nothing.  Part of 2-character time conversion.
+				} else if (PRINTF_CONVERSION_CHARS.contains(ch)) {
+					sf(tokens, pattern, mark, i, sequentialIndex++);
+					state = S1;
+					mark = i;
+				} else if (PRINTF_FORMAT_CHARS.contains(ch) || Character.isDigit(ch)) {
+					// Do nothing.
+				} else {
+					// Unknown character - could be invalid conversion or end of format
+					// Create StringFormatToken and let String.format() validate it
+					// This allows String.format() to throw IllegalFormatException for invalid conversions like %F
+					// printfStart is position after '%', so substring from printfStart-1 (the '%') to i (after the char)
+					sf(tokens, pattern, mark, i, sequentialIndex++);
+					state = S1;
+					mark = i;
+				}
+			} else if (state == S3) {
+				if (ch == '{') {
+					nestedBracketDepth++;
+				} else if (ch == '}') {
+					if (nestedBracketDepth > 0) {
+						nestedBracketDepth--;
+					} else {
+						mf(tokens, pattern, mark + 1, i - 1, sequentialIndex++);
+						state = S1;
+						mark = i;
+					}
+				}
+			} else /* if (state == S4) */ {
+				if (ch == '\'') {
+					if (mark == i - 1) {
+						lit(tokens, pattern, mark, i);  // '' becomes '
+						state = S1;
+						mark = i;
+					} else {
+						lit(tokens, pattern, mark, i - 1);
+						state = S1;
+						mark = i;
+					}
+				}
+			}
+		}
+
+		// Process remaining content based on final state
+		if (state == S1) {
+			lit(tokens, pattern, mark);
+		} else if (state == S2) {
+			// Dangling '%' without conversion - throw exception to match String.format() behavior
+			// UnknownFormatConversionException constructor takes just the conversion character
+			throw new java.util.UnknownFormatConversionException("%");
+		} else if (state == S3) {
+			// Unmatched '{' - throw exception to match MessageFormat behavior
+			throw new IllegalArgumentException("Unmatched braces in the pattern.");
+		} else /* if (state == S4) */ {
+			// Unmatched quote - MessageFormat treats it as ending the quoted section
+			// Add the quoted content as literal (from mark to end of pattern)
+			lit(tokens, pattern, mark);
+		}
+
+		return tokens;
+	}
+
+	private static void lit(List<Token> tokens, String pattern, int start, int end) {
+		if (start == end)
+			return;
+		tokens.add(new LiteralToken(pattern.substring(start, end)));
+	}
+
+	private static void lit(List<Token> tokens, String pattern, int start) {
+		if (start == pattern.length())
+			return;
+		tokens.add(new LiteralToken(pattern.substring(start)));
+	}
+
+	private static void sf(List<Token> tokens, String pattern, int start, int end, int index) {
+		tokens.add(new StringFormatToken(pattern.substring(start, end), index));
+	}
+
+	private static void mf(List<Token> tokens, String pattern, int start, int end, int index) {
+		tokens.add(new MessageFormatToken(pattern.substring(start, end), index));
+	}
+
+	private static int parseIndexMF(String s) {
+		try {
+			return Integer.parseInt(s.trim());
+		} catch (@SuppressWarnings("unused") NumberFormatException e) {
+			throw new IllegalArgumentException("can't parse argument number: " + s);
+		}
+	}
+
+	private static int parseIndexSF(String s) {
+		return Integer.parseInt(s.trim());
+	}
+
+	@Override
+	public String toString() {
+		return pattern;
+	}
+
+	/**
+	 * Returns a debug representation of the parsed pattern showing the token structure.
+	 *
+	 * <p>
+	 * This method is useful for debugging and understanding how a pattern was parsed.
+	 * It returns a string showing each token in the format:
+	 * <ul>
+	 *   <li><b>Literal tokens:</b> <js>"[L:text]"</js> - Literal text</li>
+	 *   <li><b>MessageFormat tokens (simple):</b> <js>"[M:s0]"</js> - Simple MessageFormat placeholder (format='s', index=0)</li>
+	 *   <li><b>MessageFormat tokens (complex):</b> <js>"[M:o0:{0,number,currency}]"</js> - Complex MessageFormat placeholder (format='o', index=0, content)</li>
+	 *   <li><b>StringFormat tokens (simple):</b> <js>"[S:s0:%s]"</js> - Simple printf placeholder (format='s', index=0, content)</li>
+	 *   <li><b>StringFormat tokens (complex):</b> <js>"[S:z0:%.2f]"</js> - Complex printf placeholder (format='z', index=0, content)</li>
+	 * </ul>
+	 *
+	 * <h5 class='section'>Token Format:</h5>
+	 * <ul>
+	 *   <li><b>L</b> = Literal token</li>
+	 *   <li><b>M</b> = MessageFormat token</li>
+	 *   <li><b>S</b> = StringFormat (printf) token</li>
+	 *   <li><b>Format character:</b> 's' = simple, 'o' = other/complex, 'z' = complex printf</li>
+	 *   <li><b>Index:</b> 0-based argument index</li>
+	 *   <li><b>Content:</b> The format string content (for complex tokens)</li>
+	 * </ul>
+	 *
+	 * <h5 class='section'>Examples:</h5>
+	 * <p class='bjava'>
+	 * 	FormatString <jv>fmt</jv> = FormatString.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>);
+	 * 	<jv>fmt</jv>.<jsm>toPattern</jsm>();
+	 * 	<jc>// Returns: "[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]"</jc>
+	 * </p>
+	 *
+	 * @return A debug string showing the parsed token structure.
+	 */
+	public String toPattern() {
+		return Arrays.stream(tokens).map(Object::toString).collect(Collectors.joining());
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof FormatString o2 && eq(this, o2, (x, y) -> eq(x.pattern, y.pattern));
+	}
+
+	@Override
+	public int hashCode() {
+		return pattern.hashCode();
+	}
+}
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringFormat.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringFormat.java
new file mode 100644
index 0000000000..834ea88678
--- /dev/null
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringFormat.java
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.common.utils;
+
+import static org.apache.juneau.common.utils.AssertionUtils.*;
+import static org.apache.juneau.common.utils.CollectionUtils.*;
+import static org.apache.juneau.common.utils.StateEnum.*;
+import static org.apache.juneau.common.utils.Utils.*;
+
+import java.text.*;
+import java.util.*;
+import java.util.stream.*;
+import java.util.MissingFormatArgumentException;
+
+import org.apache.juneau.common.collections.*;
+
+/**
+ * Unified string formatter supporting both MessageFormat-style and printf-style formatting in the same pattern.
+ *
+ * <p>
+ * This class provides a thread-safe, cacheable formatter that can handle mixed format styles in a single pattern.
+ * It supports both MessageFormat syntax (<js>"{0}"</js>, <js>"{1,number}"</js>) and printf syntax (<js>"%s"</js>, <js>"%d"</js>)
+ * within the same string.
+ *
+ * <h5 class='section'>Features:</h5>
+ * <ul>
+ *   <li><b>Dual Format Support:</b> Mix MessageFormat and printf-style placeholders in the same pattern</li>
+ *   <li><b>Thread-Safe:</b> Immutable class, safe for concurrent use</li>
+ *   <li><b>Cacheable:</b> Use {@link #of(String)} for cached instances</li>
+ *   <li><b>Argument Sharing:</b> Both format styles share the same argument array</li>
+ * </ul>
+ *
+ * <h5 class='section'>Format Style Detection:</h5>
+ * <p>
+ * The formatter automatically detects which style to use for each placeholder:
+ * <ul>
+ *   <li><b>MessageFormat style:</b> <js>"{0}"</js>, <js>"{1,number}"</js>, <js>"{2,date}"</js>, etc.</li>
+ *   <li><b>Printf style:</b> <js>"%s"</js>, <js>"%d"</js>, <js>"%.2f"</js>, <js>"%1$s"</js>, etc.</li>
+ * </ul>
+ *
+ * <h5 class='section'>Argument Mapping:</h5>
+ * <p>
+ * Arguments are processed in order of appearance:
+ * <ul>
+ *   <li><b>MessageFormat placeholders:</b> Use explicit indices (e.g., <js>"{0}"</js> uses <c>args[0]</c>)</li>
+ *   <li><b>Printf placeholders:</b> Use sequential indices starting after the highest MessageFormat index</li>
+ * </ul>
+ *
+ * <h5 class='section'>Examples:</h5>
+ * <p class='bjava'>
+ * 	<jc>// Mixed format styles</jc>
+ * 	StringFormat <jv>fmt</jv> = StringFormat.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>);
+ * 	String <jv>result</jv> = <jv>fmt</jv>.<jsm>format</jsm>(<js>"John"</js>, 5);
+ * 	<jc>// Returns: "Hello John, you have 5 items"</jc>
+ *
+ * 	<jc>// MessageFormat with explicit indices, printf with sequential</jc>
+ * 	StringFormat <jv>fmt2</jv> = StringFormat.<jsm>of</jsm>(<js>"User {0} has %s and {1} items"</js>);
+ * 	String <jv>result2</jv> = <jv>fmt2</jv>.<jsm>format</jsm>(<js>"Alice"</js>, 10, <js>"admin"</js>);
+ * 	<jc>// Returns: "User Alice has admin and 10 items"</jc>
+ * 	<jc>// {0} -> "Alice", {1} -> 10, %s -> "admin"</jc>
+ *
+ * 	<jc>// Printf with explicit indices</jc>
+ * 	StringFormat <jv>fmt3</jv> = StringFormat.<jsm>of</jsm>(<js>"%1$s loves %2$s, and {0} also loves %3$s"</js>);
+ * 	String <jv>result3</jv> = <jv>fmt3</jv>.<jsm>format</jsm>(<js>"Alice"</js>, <js>"Bob"</js>, <js>"Charlie"</js>);
+ * 	<jc>// Returns: "Alice loves Bob, and Alice also loves Charlie"</jc>
+ * </p>
+ *
+ * <h5 class='section'>Caching:</h5>
+ * <p>
+ * Use {@link #of(String)} to get cached instances. The cache is thread-safe and limited to 1000 entries.
+ * For uncached instances, use the constructor directly.
+ * </p>
+ *
+ * <h5 class='section'>Thread Safety:</h5>
+ * <p>
+ * This class is immutable and thread-safe. Multiple threads can safely use the same instance concurrently.
+ * </p>
+ *
+ * @see StringUtils#format(String, Object...)
+ * @see StringUtils#mformat(String, Object...)
+ */
+public final class StringFormat {
+
+	private static final Cache<String,StringFormat> CACHE = Cache.of(String.class, StringFormat.class).maxSize(1000).weak().build();
+
+	private static final Cache2<Locale,String,MessageFormat> MESSAGE_FORMAT_CACHE = Cache2.of(Locale.class, String.class, MessageFormat.class).maxSize(100).threadLocal().weak()
+		.supplier((locale, content) -> new MessageFormat(content, locale)).build();
+
+	private static final Cache<Locale,NumberFormat> NUMBER_FORMAT_CACHE = Cache.of(Locale.class, NumberFormat.class).maxSize(50).threadLocal().weak().supplier(NumberFormat::getInstance).build();
+
+	private static final Cache<Locale,DateFormat> DATE_FORMAT_CACHE = Cache.of(Locale.class, DateFormat.class).maxSize(50).threadLocal().weak()
+		.supplier(locale -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale)).build();
+
+	private static final AsciiSet PRINTF_CONVERSION_CHARS = AsciiSet.of("bBhHsScCdoxXeEfgGaAtTn%");
+	private static final AsciiSet PRINTF_FORMAT_CHARS = AsciiSet.of("-+ 0(#.*$");
+	private static final AsciiSet FORMAT_CHARS = AsciiSet.of("%{'");
+
+	/**
+	 * Formats a pattern string with the given arguments using the default locale.
+	 *
+	 * <p>
+	 * This is a convenience method that creates a StringFormat instance and formats it.
+	 * If the pattern contains no format specifiers (no '%', '{', or '''), the pattern is returned as-is.
+	 *
+	 * @param pattern The format pattern.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
+	 */
+	public static String format(String pattern, Object...args) {
+		if (!hasArgs(pattern))
+			return pattern;
+		return of(pattern).format(args);
+	}
+
+	/**
+	 * Formats a pattern string with the given arguments using the specified locale.
+	 *
+	 * <p>
+	 * This is a convenience method that creates a StringFormat instance and formats it.
+	 * If the pattern contains no format specifiers (no '%', '{', or '''), the pattern is returned as-is.
+	 *
+	 * @param pattern The format pattern.
+	 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk> or format specifiers are invalid.
+	 */
+	public static String format(String pattern, Locale locale, Object...args) {
+		if (!hasArgs(pattern))
+			return pattern;
+		return of(pattern).format(locale, args);
+	}
+
+	/**
+	 * Checks if a pattern contains any format specifiers.
+	 *
+	 * <p>
+	 * Returns <jk>true</jk> if the pattern contains any of the following characters:
+	 * <ul>
+	 *   <li><js>'%'</js> - Printf-style format specifier</li>
+	 *   <li><js>'{'</js> - MessageFormat-style placeholder</li>
+	 *   <li><js>'''</js> - MessageFormat quote</li>
+	 * </ul>
+	 *
+	 * <p>
+	 * Uses {@link AsciiSet} for efficient single-pass scanning of the string.
+	 *
+	 * @param pattern The pattern to check.
+	 * @return <jk>true</jk> if the pattern contains format specifiers, <jk>false</jk> otherwise.
+	 */
+	private static boolean hasArgs(String pattern) {
+		if (pattern == null || pattern.isEmpty())
+			return false;
+		for (var i = 0; i < pattern.length(); i++) {
+			if (FORMAT_CHARS.contains(pattern.charAt(i)))
+				return true;
+		}
+		return false;
+	}
+
+	/**
+	 * Base class for format tokens.
+	 */
+	private abstract static class Token {
+		/**
+		 * Appends the formatted content to the StringBuilder.
+		 *
+		 * @param sb The StringBuilder to append to.
+		 * @param args The arguments array.
+		 * @param locale The locale for formatting (can be null for default).
+		 */
+		abstract void append(StringBuilder sb, Object[] args, Locale locale);
+	}
+
+	/**
+	 * Literal text token.
+	 */
+	private static final class LiteralToken extends Token {
+		private final String text;
+
+		LiteralToken(String text) {
+			this.text = text;
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			sb.append(text);
+		}
+
+		@Override
+		public String toString() {
+			return "[L:" + text + "]";
+		}
+	}
+
+	/**
+	 * MessageFormat-style token (e.g., {0}, {1,number}).
+	 */
+	private static final class MessageFormatToken extends Token {
+		private final char format;
+		private final String content; // null for simple tokens, normalized pattern like "{0,number}" for complex tokens
+		private final int index; // 0-based index
+		private final String placeholder; // Original placeholder text like "{0}" or "{0,number}"
+
+		/**
+		 * @param content - The variable content such as "{}" or "{0}", or "{0,number}"
+		 * 	The content should have the curly-braces already removed so that we're only looking at the inner parts.
+		 * @param index - The zero-based index of the variable in the message (used for sequential {} placeholders).
+		 */
+		MessageFormatToken(String content, int index) {
+			if (content.isBlank()) {
+				this.content = null;
+				this.index = index;
+				this.format = 's';
+				this.placeholder = "{" + index + "}";
+			} else if (content.indexOf(',') == -1) {
+				this.content = null;
+				this.index = parseIndexMF(content);
+				this.format = 's';
+				this.placeholder = "{" + this.index + "}";
+			} else {
+				var tokens = content.split(",", 2);
+				this.index = parseIndexMF(tokens[0]);
+				this.content = "{0," + tokens[1] + "}";
+				this.format = 'o';
+				this.placeholder = "{" + this.index + "," + tokens[1] + "}";
+			}
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			// MessageFormat inserts the placeholder text if argument is missing
+			if (args == null || index >= args.length || index < 0) {
+				sb.append(placeholder);
+				return;
+			}
+			var o = args[index];
+			var l = locale == null ? Locale.getDefault() : locale;
+			switch (format) {
+				case 's':
+					if (o == null) {
+						sb.append("null");
+					} else if (o instanceof Number o2) {
+						sb.append(NUMBER_FORMAT_CACHE.get(l).format(o2));
+					} else if (o instanceof Date o2) {
+						sb.append(DATE_FORMAT_CACHE.get(l).format(o2));
+					} else {
+						sb.append(o.toString());
+					}
+					break;
+				default:
+					// Use Cache2 with Locale and content as separate keys to avoid string concatenation
+					var mf = MESSAGE_FORMAT_CACHE.get(l, content);
+					sb.append(mf.format(a(o)));
+					break;
+			}
+		}
+
+		@Override
+		public String toString() {
+			return "[M:" + format + index + (content == null ? "" : (':' + content)) + "]";
+		}
+	}
+
+	/**
+	 * Printf-style token (e.g., %s, %d, %.2f).
+	 */
+	private static final class StringFormatToken extends Token {
+		private final char format; // 's' = simple (handle directly), 'o' = other (use String.format)
+		private final String content; // The format string to pass to String.format (null for simple formats)
+		private final int index; // 0-based index
+
+		StringFormatToken(String content, int index) {
+			// content is everything after '%' (e.g., "s", "1$s", "d", ".2f", "1$.2f")
+			var $ = content.indexOf('$');
+			if ($ >= 0) {
+				index = parseIndexSF(content.substring(0, $)) - 1;
+				content = content.substring($ + 1);
+			}
+			this.format = content.length() == 1 ? content.charAt(content.length() - 1) : 'z';
+			this.index = index;
+			this.content = "%" + content;
+		}
+
+		@Override
+		void append(StringBuilder sb, Object[] args, Locale locale) {
+			// String.format() throws MissingFormatArgumentException when argument is missing
+			if (args == null || index >= args.length || index < 0) {
+				throw new MissingFormatArgumentException(content);
+			}
+			var o = args[index];
+			var l = locale == null ? Locale.getDefault() : locale;
+			var dl = locale == null || locale.equals(Locale.getDefault());
+			switch (format) {
+				case 'b':
+					// String.format() with %b converts:
+					// - null -> "false"
+					// - Boolean -> toString()
+					// - Any other non-null value -> "true"
+					if (o == null) {
+						sb.append("false");
+					} else if (o instanceof Boolean) {
+						sb.append(o.toString());
+					} else {
+						sb.append("true");
+					}
+					return;
+				case 'B':
+					// String.format() with %B converts:
+					// - null -> "FALSE"
+					// - Boolean -> toString().toUpperCase()
+					// - Any other non-null value -> "TRUE"
+					if (o == null) {
+						sb.append("FALSE");
+					} else if (o instanceof Boolean) {
+						sb.append(o.toString().toUpperCase());
+					} else {
+						sb.append("TRUE");
+					}
+					return;
+				case 's':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					sb.append(o.toString());
+					return;
+				case 'S':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					sb.append(o.toString().toUpperCase());
+					return;
+				case 'd':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Number o2) {
+						if (dl) {
+							if (o instanceof Integer || o instanceof Long || o instanceof Byte || o instanceof Short) {
+								sb.append(o);
+							} else {
+								// For other Number types (BigDecimal, BigInteger, etc.), convert to long
+								sb.append(o2.longValue());
+							}
+							return;
+						}
+						// For non-default locales, use String.format to ensure printf-style consistency
+						sb.append(sf(l, "%d", o));
+						return;
+					}
+					break;
+				case 'x':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toHexString(o2));
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toHexString(o2));
+						return;
+					}
+					break;
+				case 'X':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toHexString(o2).toUpperCase());
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toHexString(o2).toUpperCase());
+						return;
+					}
+					break;
+				case 'o':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Integer o2) {
+						sb.append(Integer.toOctalString(o2));
+						return;
+					} else if (o instanceof Long o2) {
+						sb.append(Long.toOctalString(o2));
+						return;
+					}
+					break;
+				case 'c':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					if (o instanceof Character) {
+						sb.append(o);
+						return;
+					} else if (o instanceof Integer o2) {
+						sb.append((char)o2.intValue());
+						return;
+					}
+					break;
+				case 'C':
+					if (o == null) {
+						sb.append("NULL");
+						return;
+					}
+					if (o instanceof Character o2) {
+						sb.append(Character.toUpperCase(o2));
+						return;
+					} else if (o instanceof Integer o2) {
+						sb.append(Character.toUpperCase((char)o2.intValue()));
+						return;
+					}
+					break;
+				case 'f':
+					if (o == null) {
+						sb.append("null");
+						return;
+					}
+					// Always use String.format() to match exact behavior (precision, etc.)
+					if (o instanceof Number) {
+						sb.append(sf(l, "%f", o));
+						return;
+					}
+					break;
+				default:
+					break;
+			}
+
+			// Fallback to String.format for any other simple format
+			sb.append(sf(l, content, o));
+		}
+
+		@Override
+		public String toString() {
+			return "[S:" + format + index + ":" + content + "]";
+		}
+	}
+
+	private static String sf(Locale l, String s, Object o) {
+		return String.format(l, s, a(o));
+	}
+
+	private final String pattern;
+	private final Token[] tokens;
+
+	/**
+	 * Creates a new StringFormat instance.
+	 *
+	 * @param pattern The format pattern. Can contain both MessageFormat and printf-style placeholders.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
+	 */
+	public StringFormat(String pattern) {
+		this.pattern = assertArgNotNull("pattern", pattern);
+		this.tokens = parseTokens(pattern).toArray(Token[]::new);
+	}
+
+	/**
+	 * Returns a cached StringFormat instance for the given pattern.
+	 *
+	 * <p>
+	 * This method uses a thread-safe cache to avoid recreating StringFormat instances for the same pattern.
+	 * The cache is limited to 1000 entries.
+	 *
+	 * @param pattern The format pattern.
+	 * @return A cached or new StringFormat instance.
+	 * @throws IllegalArgumentException If the pattern is <jk>null</jk>.
+	 */
+	public static StringFormat of(String pattern) {
+		assertArgNotNull("pattern", pattern);
+		return CACHE.get(pattern, () -> new StringFormat(pattern));
+	}
+
+	/**
+	 * Formats the pattern with the given arguments using the default locale.
+	 *
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
+	 */
+	public String format(Object...args) {
+		return format(Locale.getDefault(), args);
+	}
+
+	/**
+	 * Formats the pattern with the given arguments using the specified locale.
+	 *
+	 * <p>
+	 * The locale affects both MessageFormat and printf-style formatting:
+	 * <ul>
+	 *   <li><b>MessageFormat:</b> Locale-specific number, date, and time formatting</li>
+	 *   <li><b>Printf:</b> Locale-specific number formatting (decimal separators, etc.)</li>
+	 * </ul>
+	 *
+	 * @param locale The locale to use for formatting. If <jk>null</jk>, uses the default locale.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws IllegalArgumentException If format specifiers are invalid or arguments don't match.
+	 */
+	public String format(Locale locale, Object...args) {
+		var sb = new StringBuilder(pattern.length() + 64);
+		for (var token : tokens) {
+			token.append(sb, args, locale);
+		}
+		return sb.toString();
+	}
+
+	/**
+	 * Parses the pattern into a list of tokens.
+	 */
+	private static List<Token> parseTokens(String pattern) {
+		var tokens = new ArrayList<Token>();
+		var length = pattern.length();
+		var i = 0;
+		var sequentialIndex = 0; // 0-based index for sequential placeholders
+
+		// Possible String.format variable formats:
+		// %[argument_index$][flags][width][.precision]conversion
+		// %[argument_index$][flags][width]conversion
+		// %[flags][width]conversion
+
+		// Possible MessageFormat variable formats:
+		// {}
+		// {#,formatType}
+		// {#,formatType,formatStyle}
+
+		// S1 - In literal, looking for %, {, or '
+		// S2 - Found %, looking for conversion char or t or T
+		// S3 - Found {, looking for }
+		// S4 - Found ', in quoted section (MessageFormat single quotes escape special chars), looking for '
+		var state = S1;
+
+		var nestedBracketDepth = 0;
+
+		var mark = 0;
+		while (i < length) {
+			var ch = pattern.charAt(i++);
+
+			if (state == S1) {
+				if (ch == '%') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S2;
+					mark = i;
+				} else if (ch == '{') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S3;
+					mark = i - 1;
+				} else if (ch == '\'') {
+					lit(tokens, pattern, mark, i - 1);
+					state = S4;
+					mark = i;
+				}
+			} else if (state == S2) {
+				if (ch == '%') {
+					tokens.add(new LiteralToken("%"));
+					state = S1;
+					mark = i;
+				} else if (ch == 't' || ch == 'T') {
+					// Do nothing.  Part of 2-character time conversion.
+				} else if (PRINTF_CONVERSION_CHARS.contains(ch)) {
+					sf(tokens, pattern, mark, i, sequentialIndex++);
+					state = S1;
+					mark = i;
+				} else if (PRINTF_FORMAT_CHARS.contains(ch) || Character.isDigit(ch)) {
+					// Do nothing.
+				} else {
+					// Unknown character - could be invalid conversion or end of format
+					// Create StringFormatToken and let String.format() validate it
+					// This allows String.format() to throw IllegalFormatException for invalid conversions like %F
+					// printfStart is position after '%', so substring from printfStart-1 (the '%') to i (after the char)
+					sf(tokens, pattern, mark, i, sequentialIndex++);
+					state = S1;
+					mark = i;
+				}
+			} else if (state == S3) {
+				if (ch == '{') {
+					nestedBracketDepth++;
+				} else if (ch == '}') {
+					if (nestedBracketDepth > 0) {
+						nestedBracketDepth--;
+					} else {
+						mf(tokens, pattern, mark + 1, i - 1, sequentialIndex++);
+						state = S1;
+						mark = i;
+					}
+				}
+			} else /* if (state == S4) */ {
+				if (ch == '\'') {
+					if (mark == i - 1) {
+						lit(tokens, pattern, mark, i);  // '' becomes '
+						state = S1;
+						mark = i;
+					} else {
+						lit(tokens, pattern, mark, i - 1);
+						state = S1;
+						mark = i;
+					}
+				}
+			}
+		}
+
+		// Process remaining content based on final state
+		if (state == S1) {
+			lit(tokens, pattern, mark);
+		} else if (state == S2) {
+			// Dangling '%' without conversion - throw exception to match String.format() behavior
+			// UnknownFormatConversionException constructor takes just the conversion character
+			throw new java.util.UnknownFormatConversionException("%");
+		} else if (state == S3) {
+			// Unmatched '{' - throw exception to match MessageFormat behavior
+			throw new IllegalArgumentException("Unmatched braces in the pattern.");
+		} else /* if (state == S4) */ {
+			// Unmatched quote - MessageFormat treats it as ending the quoted section
+			// Add the quoted content as literal (from mark to end of pattern)
+			lit(tokens, pattern, mark);
+		}
+
+		return tokens;
+	}
+
+	private static void lit(List<Token> tokens, String pattern, int start, int end) {
+		if (start == end)
+			return;
+		tokens.add(new LiteralToken(pattern.substring(start, end)));
+	}
+
+	private static void lit(List<Token> tokens, String pattern, int start) {
+		if (start == pattern.length())
+			return;
+		tokens.add(new LiteralToken(pattern.substring(start)));
+	}
+
+	private static void sf(List<Token> tokens, String pattern, int start, int end, int index) {
+		tokens.add(new StringFormatToken(pattern.substring(start, end), index));
+	}
+
+	private static void mf(List<Token> tokens, String pattern, int start, int end, int index) {
+		tokens.add(new MessageFormatToken(pattern.substring(start, end), index));
+	}
+
+	private static int parseIndexMF(String s) {
+		try {
+			return Integer.parseInt(s.trim());
+		} catch (@SuppressWarnings("unused") NumberFormatException e) {
+			throw new IllegalArgumentException("can't parse argument number: " + s);
+		}
+	}
+
+	private static int parseIndexSF(String s) {
+		return Integer.parseInt(s.trim());
+	}
+
+	@Override
+	public String toString() {
+		return pattern;
+	}
+
+	/**
+	 * Returns a debug representation of the parsed pattern showing the token structure.
+	 *
+	 * <p>
+	 * This method is useful for debugging and understanding how a pattern was parsed.
+	 * It returns a string showing each token in the format:
+	 * <ul>
+	 *   <li><b>Literal tokens:</b> <js>"[L:text]"</js> - Literal text</li>
+	 *   <li><b>MessageFormat tokens (simple):</b> <js>"[M:s0]"</js> - Simple MessageFormat placeholder (format='s', index=0)</li>
+	 *   <li><b>MessageFormat tokens (complex):</b> <js>"[M:o0:{0,number,currency}]"</js> - Complex MessageFormat placeholder (format='o', index=0, content)</li>
+	 *   <li><b>StringFormat tokens (simple):</b> <js>"[S:s0:%s]"</js> - Simple printf placeholder (format='s', index=0, content)</li>
+	 *   <li><b>StringFormat tokens (complex):</b> <js>"[S:z0:%.2f]"</js> - Complex printf placeholder (format='z', index=0, content)</li>
+	 * </ul>
+	 *
+	 * <h5 class='section'>Token Format:</h5>
+	 * <ul>
+	 *   <li><b>L</b> = Literal token</li>
+	 *   <li><b>M</b> = MessageFormat token</li>
+	 *   <li><b>S</b> = StringFormat (printf) token</li>
+	 *   <li><b>Format character:</b> 's' = simple, 'o' = other/complex, 'z' = complex printf</li>
+	 *   <li><b>Index:</b> 0-based argument index</li>
+	 *   <li><b>Content:</b> The format string content (for complex tokens)</li>
+	 * </ul>
+	 *
+	 * <h5 class='section'>Examples:</h5>
+	 * <p class='bjava'>
+	 * 	StringFormat <jv>fmt</jv> = StringFormat.<jsm>of</jsm>(<js>"Hello {0}, you have %d items"</js>);
+	 * 	<jv>fmt</jv>.<jsm>toPattern</jsm>();
+	 * 	<jc>// Returns: "[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]"</jc>
+	 * </p>
+	 *
+	 * @return A debug string showing the parsed token structure.
+	 */
+	public String toPattern() {
+		return Arrays.stream(tokens).map(Object::toString).collect(Collectors.joining());
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof StringFormat o2 && eq(this, o2, (x, y) -> eq(x.pattern, y.pattern));
+	}
+
+	@Override
+	public int hashCode() {
+		return pattern.hashCode();
+	}
+}
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringUtils.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringUtils.java
index a273e86014..f342752c0f 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringUtils.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/StringUtils.java
@@ -105,7 +105,7 @@ public class StringUtils {
 	 *   <li>Object allocation for frequently used patterns</li>
 	 * </ul>
 	 */
-	private static final ThreadLocal<Cache<String,MessageFormat>> MESSAGE_FORMAT_CACHE = ThreadLocal.withInitial(() -> Cache.of(String.class, MessageFormat.class).maxSize(100).build());
+	private static final Cache<String,MessageFormat> MESSAGE_FORMAT_CACHE = Cache.of(String.class, MessageFormat.class).maxSize(100).threadLocal().build();
 
 	private static final AsciiSet numberChars = AsciiSet.of("-xX.+-#pP0123456789abcdefABCDEF");
 
@@ -746,9 +746,8 @@ public class StringUtils {
 		if (c % 2 != 0)
 			throw new AssertionError("Dangling single quote found in pattern: " + pattern);
 
-		// Get or create a cached MessageFormat for this pattern (thread-safe via ThreadLocal)
-		var cache = MESSAGE_FORMAT_CACHE.get();
-		var mf = cache.get(pattern, () -> new MessageFormat(pattern));
+		// Get or create a cached MessageFormat for this pattern (thread-safe via thread-local cache)
+		var mf = MESSAGE_FORMAT_CACHE.get(pattern, () -> new MessageFormat(pattern));
 
 		// Determine which arguments have format types and need to preserve their original type
 		var formats = mf.getFormatsByArgumentIndex();
@@ -764,6 +763,97 @@ public class StringUtils {
 		return mf.format(args2);
 	}
 
+	/**
+	 * Formats a string using printf-style format specifiers.
+	 *
+	 * <p>
+	 * This method provides printf-style formatting similar to C's <c>printf()</c> function and Java's
+	 * {@link String#format(String, Object...)}. It uses the same format specifier syntax.
+	 *
+	 * <p>
+	 * This is the primary formatting method using printf-style syntax (e.g., <js>"%s"</js>, <js>"%d"</js>, <js>"%f"</js>).
+	 * For MessageFormat-style formatting, use {@link #mformat(String, Object...)}.
+	 *
+	 * <h5 class='section'>Format Specifiers:</h5>
+	 * <ul>
+	 *   <li><b>%s</b> - String</li>
+	 *   <li><b>%d</b> - Decimal integer</li>
+	 *   <li><b>%f</b> - Floating point</li>
+	 *   <li><b>%x</b> - Hexadecimal (lowercase)</li>
+	 *   <li><b>%X</b> - Hexadecimal (uppercase)</li>
+	 *   <li><b>%o</b> - Octal</li>
+	 *   <li><b>%b</b> - Boolean</li>
+	 *   <li><b>%c</b> - Character</li>
+	 *   <li><b>%e</b> - Scientific notation (lowercase)</li>
+	 *   <li><b>%E</b> - Scientific notation (uppercase)</li>
+	 *   <li><b>%g</b> - General format (lowercase)</li>
+	 *   <li><b>%G</b> - General format (uppercase)</li>
+	 *   <li><b>%n</b> - Platform-specific line separator</li>
+	 *   <li><b>%%</b> - Literal percent sign</li>
+	 * </ul>
+	 *
+	 * <h5 class='section'>Format Specifier Syntax:</h5>
+	 * <p>
+	 * Format specifiers follow this pattern: <c>%[argument_index$][flags][width][.precision]conversion</c>
+	 * </p>
+	 *
+	 * <h5 class='section'>Examples:</h5>
+	 * <p class='bjava'>
+	 * 	<jc>// Basic string and number formatting</jc>
+	 * 	format(<js>"Hello %s, you have %d items"</js>, <js>"John"</js>, 5);
+	 * 	<jc>// Returns: "Hello John, you have 5 items"</jc>
+	 *
+	 * 	<jc>// Floating point with precision</jc>
+	 * 	format(<js>"Price: $%.2f"</js>, 19.99);
+	 * 	<jc>// Returns: "Price: $19.99"</jc>
+	 *
+	 * 	<jc>// Width and alignment</jc>
+	 * 	format(<js>"Name: %-20s Age: %3d"</js>, <js>"John"</js>, 25);
+	 * 	<jc>// Returns: "Name: John                 Age:  25"</jc>
+	 *
+	 * 	<jc>// Hexadecimal</jc>
+	 * 	format(<js>"Color: #%06X"</js>, 0xFF5733);
+	 * 	<jc>// Returns: "Color: #FF5733"</jc>
+	 *
+	 * 	<jc>// Scientific notation</jc>
+	 * 	format(<js>"Value: %.2e"</js>, 1234567.0);
+	 * 	<jc>// Returns: "Value: 1.23e+06"</jc>
+	 *
+	 * 	<jc>// Argument index (reuse arguments)</jc>
+	 * 	format(<js>"%1$s loves %2$s, and %1$s also loves %3$s"</js>, <js>"Alice"</js>, <js>"Bob"</js>, <js>"Charlie"</js>);
+	 * 	<jc>// Returns: "Alice loves Bob, and Alice also loves Charlie"</jc>
+	 * </p>
+	 *
+	 * <h5 class='section'>Comparison with mformat():</h5>
+	 * <p class='bjava'>
+	 * 	<jc>// Printf style (this method)</jc>
+	 * 	format(<js>"Hello %s, you have %d items"</js>, <js>"John"</js>, 5);
+	 *
+	 * 	<jc>// MessageFormat style</jc>
+	 * 	mformat(<js>"Hello {0}, you have {1} items"</js>, <js>"John"</js>, 5);
+	 * </p>
+	 *
+	 * <h5 class='section'>Null Handling:</h5>
+	 * <p>
+	 * Null arguments are formatted as the string <js>"null"</js> for string conversions,
+	 * or cause a {@link NullPointerException} for numeric conversions (consistent with {@link String#format(String, Object...)}).
+	 * </p>
+	 *
+	 * @param pattern The printf-style format string.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @throws java.util.IllegalFormatException If the format string is invalid or arguments don't match the format specifiers.
+	 * @see String#format(String, Object...)
+	 * @see #mformat(String, Object...) for MessageFormat-style formatting
+	 */
+	public static String format(String pattern, Object...args) {
+		if (pattern == null)
+			return null;
+		if (args == null || args.length == 0)
+			return pattern;
+		return String.format(pattern, args);
+	}
+
 	/**
 	 * Converts a hexadecimal character string to a byte array.
 	 *
@@ -1716,7 +1806,7 @@ public class StringUtils {
 		// Remove spaces and hyphens
 		var cleaned = str.replaceAll("[\\s\\-]", "");
 		// Must be all digits and 13-19 digits long
-		if (!cleaned.matches("^\\d{13,19}$"))
+		if (! cleaned.matches("^\\d{13,19}$"))
 			return false;
 		// Apply Luhn algorithm
 		var sum = 0;
@@ -1729,7 +1819,7 @@ public class StringUtils {
 					digit = (digit % 10) + 1;
 			}
 			sum += digit;
-			alternate = !alternate;
+			alternate = ! alternate;
 		}
 		return (sum % 10) == 0;
 	}
@@ -1845,7 +1935,7 @@ public class StringUtils {
 			return false;
 		try {
 			// Try IPv4 first
-			if (ip.contains(".") && !ip.contains(":")) {
+			if (ip.contains(".") && ! ip.contains(":")) {
 				var parts = ip.split("\\.");
 				if (parts.length != 4)
 					return false;
@@ -1896,12 +1986,12 @@ public class StringUtils {
 	public static boolean isValidMacAddress(String mac) {
 		if (isEmpty(mac))
 			return false;
-		
+
 		// Remove separators and check if it's 12 hex digits
 		var cleaned = mac.replaceAll("[:-]", "").toUpperCase();
 		if (cleaned.length() != 12)
 			return false;
-		
+
 		// Check if all characters are valid hex digits
 		return cleaned.matches("^[0-9A-F]{12}$");
 	}
@@ -1933,41 +2023,41 @@ public class StringUtils {
 	public static boolean isValidHostname(String hostname) {
 		if (isEmpty(hostname))
 			return false;
-		
+
 		// Cannot start or end with a dot
 		if (hostname.startsWith(".") || hostname.endsWith("."))
 			return false;
-		
+
 		// Total length cannot exceed 253 characters
 		if (hostname.length() > 253)
 			return false;
-		
+
 		// Split by dots (use -1 to preserve trailing empty strings)
 		var labels = hostname.split("\\.", -1);
-		
+
 		// Must have at least one label
 		if (labels.length == 0)
 			return false;
-		
+
 		// Check each label
 		for (var label : labels) {
 			// Label cannot be empty
 			if (label.isEmpty())
 				return false;
-			
+
 			// Label cannot exceed 63 characters
 			if (label.length() > 63)
 				return false;
-			
+
 			// Label cannot start or end with hyphen
 			if (label.startsWith("-") || label.endsWith("-"))
 				return false;
-			
+
 			// Label can only contain letters, digits, and hyphens
-			if (!label.matches("^[a-zA-Z0-9-]+$"))
+			if (! label.matches("^[a-zA-Z0-9-]+$"))
 				return false;
 		}
-		
+
 		return true;
 	}
 
@@ -1991,14 +2081,14 @@ public class StringUtils {
 	public static int wordCount(String str) {
 		if (isEmpty(str))
 			return 0;
-		
+
 		var count = 0;
 		var inWord = false;
-		
+
 		for (var i = 0; i < str.length(); i++) {
 			var c = str.charAt(i);
 			if (Character.isLetterOrDigit(c) || c == '_') {
-				if (!inWord) {
+				if (! inWord) {
 					count++;
 					inWord = true;
 				}
@@ -2006,7 +2096,7 @@ public class StringUtils {
 				inWord = false;
 			}
 		}
-		
+
 		return count;
 	}
 
@@ -2029,7 +2119,7 @@ public class StringUtils {
 	public static int lineCount(String str) {
 		if (isEmpty(str))
 			return 0;
-		
+
 		var count = 1; // At least one line
 		for (var i = 0; i < str.length(); i++) {
 			var c = str.charAt(i);
@@ -2043,7 +2133,7 @@ public class StringUtils {
 				count++;
 			}
 		}
-		
+
 		return count;
 	}
 
@@ -2066,11 +2156,11 @@ public class StringUtils {
 	public static char mostFrequentChar(String str) {
 		if (isEmpty(str))
 			return '\0';
-		
+
 		var charCounts = new int[Character.MAX_VALUE + 1];
 		var maxCount = 0;
 		var maxChar = '\0';
-		
+
 		// Count occurrences of each character
 		for (var i = 0; i < str.length(); i++) {
 			var c = str.charAt(i);
@@ -2080,7 +2170,7 @@ public class StringUtils {
 				maxChar = c;
 			}
 		}
-		
+
 		return maxChar;
 	}
 
@@ -2106,26 +2196,26 @@ public class StringUtils {
 	public static double entropy(String str) {
 		if (isEmpty(str))
 			return 0.0;
-		
+
 		var length = str.length();
 		if (length == 0)
 			return 0.0;
-		
+
 		// Count character frequencies
 		var charCounts = new int[Character.MAX_VALUE + 1];
 		for (var i = 0; i < length; i++) {
 			charCounts[str.charAt(i)]++;
 		}
-		
+
 		// Calculate entropy
 		var entropy = 0.0;
 		for (var count : charCounts) {
 			if (count > 0) {
-				var probability = (double) count / length;
+				var probability = (double)count / length;
 				entropy -= probability * (Math.log(probability) / Math.log(2.0));
 			}
 		}
-		
+
 		return entropy;
 	}
 
@@ -2152,11 +2242,11 @@ public class StringUtils {
 	public static double readabilityScore(String str) {
 		if (isEmpty(str))
 			return 0.0;
-		
+
 		var words = extractWords(str);
 		if (words.isEmpty())
 			return 0.0;
-		
+
 		// Count sentences (ending with . ! ?)
 		var sentenceCount = 0;
 		for (var i = 0; i < str.length(); i++) {
@@ -2167,22 +2257,22 @@ public class StringUtils {
 		}
 		if (sentenceCount == 0)
 			sentenceCount = 1; // At least one sentence
-		
+
 		// Calculate average words per sentence
-		var avgWordsPerSentence = (double) words.size() / sentenceCount;
-		
+		var avgWordsPerSentence = (double)words.size() / sentenceCount;
+
 		// Estimate average syllables per word (simplified: count vowel groups)
 		var totalSyllables = 0;
 		for (var word : words) {
 			totalSyllables += estimateSyllables(word);
 		}
-		var avgSyllablesPerWord = (double) totalSyllables / words.size();
-		
+		var avgSyllablesPerWord = (double)totalSyllables / words.size();
+
 		// Simplified Flesch Reading Ease formula
 		// Score = 206.835 - (1.015 * ASL) - (84.6 * ASW)
 		// Where ASL = average sentence length (words), ASW = average syllables per word
 		var score = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
-		
+
 		// Clamp to 0-100 range
 		return Math.max(0.0, Math.min(100.0, score));
 	}
@@ -2193,26 +2283,26 @@ public class StringUtils {
 	private static int estimateSyllables(String word) {
 		if (word == null || word.isEmpty())
 			return 1;
-		
+
 		var lower = word.toLowerCase();
 		var count = 0;
 		var prevWasVowel = false;
-		
+
 		for (var i = 0; i < lower.length(); i++) {
 			var c = lower.charAt(i);
 			var isVowel = (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' || c == 'y');
-			
-			if (isVowel && !prevWasVowel) {
+
+			if (isVowel && ! prevWasVowel) {
 				count++;
 			}
 			prevWasVowel = isVowel;
 		}
-		
+
 		// Handle silent 'e' at the end
 		if (lower.endsWith("e") && count > 1) {
 			count--;
 		}
-		
+
 		// At least one syllable
 		return Math.max(1, count);
 	}
@@ -2422,7 +2512,7 @@ public class StringUtils {
 	 * @param args The map containing the named argument values.
 	 * @return The formatted string with placeholders replaced, or the original template if args is null or empty.
 	 */
-	public static String formatWithNamedArgs(String template, Map<String, Object> args) {
+	public static String formatWithNamedArgs(String template, Map<String,Object> args) {
 		if (template == null)
 			return null;
 		if (args == null || args.isEmpty())
@@ -2448,16 +2538,16 @@ public class StringUtils {
 	 * @param variables The map containing the variable values.
 	 * @return The interpolated string with variables replaced, or the original template if variables is null or empty.
 	 */
-	public static String interpolate(String template, Map<String, Object> variables) {
+	public static String interpolate(String template, Map<String,Object> variables) {
 		if (template == null)
 			return null;
 		if (variables == null || variables.isEmpty())
 			return template;
-		
+
 		var result = new StringBuilder();
 		var i = 0;
 		var length = template.length();
-		
+
 		while (i < length) {
 			var dollarIndex = template.indexOf("${", i);
 			if (dollarIndex == -1) {
@@ -2465,10 +2555,10 @@ public class StringUtils {
 				result.append(template.substring(i));
 				break;
 			}
-			
+
 			// Append text before the variable
 			result.append(template.substring(i, dollarIndex));
-			
+
 			// Find the closing brace
 			var braceIndex = template.indexOf('}', dollarIndex + 2);
 			if (braceIndex == -1) {
@@ -2476,11 +2566,11 @@ public class StringUtils {
 				result.append(template.substring(dollarIndex));
 				break;
 			}
-			
+
 			// Extract variable name
 			var varName = template.substring(dollarIndex + 2, braceIndex);
 			var value = variables.get(varName);
-			
+
 			if (variables.containsKey(varName)) {
 				// Variable exists in map (even if null)
 				result.append(value != null ? value.toString() : "null");
@@ -2488,10 +2578,10 @@ public class StringUtils {
 				// Variable not found, keep the original placeholder
 				result.append("${").append(varName).append("}");
 			}
-			
+
 			i = braceIndex + 1;
 		}
-		
+
 		return result.toString();
 	}
 
@@ -2519,25 +2609,23 @@ public class StringUtils {
 			return word;
 		if (count == 1)
 			return word;
-		
+
 		var lower = word.toLowerCase();
 		var length = word.length();
-		
+
 		// Words ending in s, x, z, ch, sh -> add "es"
-		if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z") ||
-			lower.endsWith("ch") || lower.endsWith("sh")) {
+		if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z") || lower.endsWith("ch") || lower.endsWith("sh")) {
 			return word + "es";
 		}
-		
+
 		// Words ending in "y" preceded by a consonant -> replace "y" with "ies"
 		if (length > 1 && lower.endsWith("y")) {
 			var secondLast = lower.charAt(length - 2);
-			if (secondLast != 'a' && secondLast != 'e' && secondLast != 'i' && 
-				secondLast != 'o' && secondLast != 'u') {
+			if (secondLast != 'a' && secondLast != 'e' && secondLast != 'i' && secondLast != 'o' && secondLast != 'u') {
 				return word.substring(0, length - 1) + "ies";
 			}
 		}
-		
+
 		// Words ending in "f" or "fe" -> replace with "ves" (basic rule)
 		if (lower.endsWith("f")) {
 			return word.substring(0, length - 1) + "ves";
@@ -2545,7 +2633,7 @@ public class StringUtils {
 		if (lower.endsWith("fe")) {
 			return word.substring(0, length - 2) + "ves";
 		}
-		
+
 		// Default: add "s"
 		return word + "s";
 	}
@@ -2569,7 +2657,7 @@ public class StringUtils {
 	public static String ordinal(int number) {
 		var abs = Math.abs(number);
 		var suffix = "th";
-		
+
 		// Special cases for 11, 12, 13 (all use "th")
 		if (abs % 100 != 11 && abs % 100 != 12 && abs % 100 != 13) {
 			var lastDigit = abs % 10;
@@ -2580,7 +2668,7 @@ public class StringUtils {
 			else if (lastDigit == 3)
 				suffix = "rd";
 		}
-		
+
 		return number + suffix;
 	}
 
@@ -2672,12 +2760,7 @@ public class StringUtils {
 		if (str == null)
 			return null;
 		// Must unescape &amp; last to avoid interfering with other entities
-		return str.replace("&lt;", "<")
-			.replace("&gt;", ">")
-			.replace("&quot;", "\"")
-			.replace("&#39;", "'")
-			.replace("&apos;", "'")
-			.replace("&amp;", "&");
+		return str.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'").replace("&apos;", "'").replace("&amp;", "&");
 	}
 
 	/**
@@ -2745,11 +2828,7 @@ public class StringUtils {
 		if (str == null)
 			return null;
 		// Must unescape &amp; last to avoid interfering with other entities
-		return str.replace("&lt;", "<")
-			.replace("&gt;", ">")
-			.replace("&quot;", "\"")
-			.replace("&apos;", "'")
-			.replace("&amp;", "&");
+		return str.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&");
 	}
 
 	/**
@@ -2793,20 +2872,8 @@ public class StringUtils {
 		if (str == null)
 			return null;
 		// Escape regex special characters: . * + ? ^ $ { } ( ) [ ] | \
-		return str.replace("\\", "\\\\")
-			.replace(".", "\\.")
-			.replace("*", "\\*")
-			.replace("+", "\\+")
-			.replace("?", "\\?")
-			.replace("^", "\\^")
-			.replace("$", "\\$")
-			.replace("{", "\\{")
-			.replace("}", "\\}")
-			.replace("(", "\\(")
-			.replace(")", "\\)")
-			.replace("[", "\\[")
-			.replace("]", "\\]")
-			.replace("|", "\\|");
+		return str.replace("\\", "\\\\").replace(".", "\\.").replace("*", "\\*").replace("+", "\\+").replace("?", "\\?").replace("^", "\\^").replace("$", "\\$").replace("{", "\\{").replace("}", "\\}")
+			.replace("(", "\\(").replace(")", "\\)").replace("[", "\\[").replace("]", "\\]").replace("|", "\\|");
 	}
 
 	/**
@@ -2883,16 +2950,16 @@ public class StringUtils {
 			return -1;
 		if (str2 == null)
 			return 1;
-		
+
 		var len1 = str1.length();
 		var len2 = str2.length();
 		var i1 = 0;
 		var i2 = 0;
-		
+
 		while (i1 < len1 && i2 < len2) {
 			var c1 = str1.charAt(i1);
 			var c2 = str2.charAt(i2);
-			
+
 			// If both are digits, compare numerically
 			if (Character.isDigit(c1) && Character.isDigit(c2)) {
 				// Skip leading zeros
@@ -2900,7 +2967,7 @@ public class StringUtils {
 					i1++;
 				while (i2 < len2 && str2.charAt(i2) == '0')
 					i2++;
-				
+
 				// Find end of number sequences
 				var end1 = i1;
 				var end2 = i2;
@@ -2908,13 +2975,13 @@ public class StringUtils {
 					end1++;
 				while (end2 < len2 && Character.isDigit(str2.charAt(end2)))
 					end2++;
-				
+
 				// Compare lengths first (longer number is larger)
 				var lenNum1 = end1 - i1;
 				var lenNum2 = end2 - i2;
 				if (lenNum1 != lenNum2)
 					return lenNum1 - lenNum2;
-				
+
 				// Same length, compare digit by digit
 				for (var j = 0; j < lenNum1; j++) {
 					var d1 = str1.charAt(i1 + j);
@@ -2922,7 +2989,7 @@ public class StringUtils {
 					if (d1 != d2)
 						return d1 - d2;
 				}
-				
+
 				i1 = end1;
 				i2 = end2;
 			} else {
@@ -2934,7 +3001,7 @@ public class StringUtils {
 				i2++;
 			}
 		}
-		
+
 		return len1 - len2;
 	}
 
@@ -2960,18 +3027,18 @@ public class StringUtils {
 			str1 = "";
 		if (str2 == null)
 			str2 = "";
-		
+
 		var len1 = str1.length();
 		var len2 = str2.length();
-		
+
 		// Use dynamic programming with optimized space (only need previous row)
 		var prev = new int[len2 + 1];
 		var curr = new int[len2 + 1];
-		
+
 		// Initialize first row
 		for (var j = 0; j <= len2; j++)
 			prev[j] = j;
-		
+
 		for (var i = 1; i <= len1; i++) {
 			curr[0] = i;
 			for (var j = 1; j <= len2; j++) {
@@ -2986,7 +3053,7 @@ public class StringUtils {
 			prev = curr;
 			curr = temp;
 		}
-		
+
 		return prev[len2];
 	}
 
@@ -3012,14 +3079,14 @@ public class StringUtils {
 			str1 = "";
 		if (str2 == null)
 			str2 = "";
-		
+
 		if (str1.equals(str2))
 			return 1.0;
-		
+
 		var maxLen = Math.max(str1.length(), str2.length());
 		if (maxLen == 0)
 			return 1.0;
-		
+
 		var distance = levenshteinDistance(str1, str2);
 		return 1.0 - ((double)distance / maxLen);
 	}
@@ -3959,7 +4026,7 @@ public class StringUtils {
 	 * @param remove The substrings to remove.
 	 * @return The string with all specified substrings removed, or <jk>null</jk> if input is <jk>null</jk>.
 	 */
-	public static String removeAll(String str, String... remove) {
+	public static String removeAll(String str, String...remove) {
 		if (str == null)
 			return null;
 		if (isEmpty(str) || remove == null || remove.length == 0)
@@ -4140,14 +4207,14 @@ public class StringUtils {
 			// Split into words first, then combine words that fit
 			var words = line.split(" +");  // Split on one or more spaces
 			var currentLine = new StringBuilder();
-			
+
 			for (var word : words) {
 				if (word.isEmpty())
 					continue;
-					
+
 				var wordLength = word.length();
 				var currentLength = currentLine.length();
-				
+
 				if (currentLength == 0) {
 					// First word on line
 					// Only break single words if there are multiple words in the input
@@ -4205,7 +4272,7 @@ public class StringUtils {
 					}
 				}
 			}
-			
+
 			// Append any remaining line
 			if (currentLine.length() > 0) {
 				if (result.length() > 0)
@@ -5081,11 +5148,11 @@ public class StringUtils {
 	 * @param trimKeys If <jk>true</jk>, trims whitespace from keys and values.
 	 * @return A map containing the parsed key-value pairs, or an empty map if the string is <jk>null</jk> or empty.
 	 */
-	public static Map<String, String> parseMap(String str, char keyValueDelimiter, char entryDelimiter, boolean trimKeys) {
-		var result = new LinkedHashMap<String, String>();
+	public static Map<String,String> parseMap(String str, char keyValueDelimiter, char entryDelimiter, boolean trimKeys) {
+		var result = new LinkedHashMap<String,String>();
 		if (isEmpty(str))
 			return result;
-		
+
 		var entries = split(str, entryDelimiter);
 		for (var entry : entries) {
 			if (isEmpty(entry))
@@ -5128,7 +5195,7 @@ public class StringUtils {
 	public static List<String> extractNumbers(String str) {
 		if (isEmpty(str))
 			return Collections.emptyList();
-		
+
 		var result = new ArrayList<String>();
 		var pattern = Pattern.compile("\\d+(?:\\.\\d+)?");
 		var matcher = pattern.matcher(str);
@@ -5156,7 +5223,7 @@ public class StringUtils {
 	public static List<String> extractEmails(String str) {
 		if (isEmpty(str))
 			return Collections.emptyList();
-		
+
 		var result = new ArrayList<String>();
 		// Email regex pattern (same as isEmail but without ^ and $ anchors)
 		var pattern = Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
@@ -5185,7 +5252,7 @@ public class StringUtils {
 	public static List<String> extractUrls(String str) {
 		if (isEmpty(str))
 			return Collections.emptyList();
-		
+
 		var result = new ArrayList<String>();
 		// Basic URL pattern: protocol://domain/path
 		var pattern = Pattern.compile("(?:https?|ftp)://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+", Pattern.CASE_INSENSITIVE);
@@ -5214,7 +5281,7 @@ public class StringUtils {
 	public static List<String> extractWords(String str) {
 		if (isEmpty(str))
 			return Collections.emptyList();
-		
+
 		var result = new ArrayList<String>();
 		// Word pattern: sequence of word characters (letters, digits, underscore)
 		var pattern = Pattern.compile("\\w+");
@@ -5247,7 +5314,7 @@ public class StringUtils {
 	public static List<String> extractBetween(String str, String start, String end) {
 		if (isEmpty(str) || isEmpty(start) || isEmpty(end))
 			return Collections.emptyList();
-		
+
 		var result = new ArrayList<String>();
 		var startIndex = 0;
 		while (true) {
@@ -5292,7 +5359,7 @@ public class StringUtils {
 			return str;
 		if (fromChars.length() != toChars.length())
 			throw new IllegalArgumentException("fromChars and toChars must have the same length");
-		
+
 		var sb = new StringBuilder(str.length());
 		for (var i = 0; i < str.length(); i++) {
 			var c = str.charAt(i);
@@ -5325,16 +5392,16 @@ public class StringUtils {
 	public static String soundex(String str) {
 		if (isEmpty(str))
 			return null;
-		
+
 		var upper = str.toUpperCase();
 		var result = new StringBuilder(4);
 		result.append(upper.charAt(0));
-		
+
 		// Soundex mapping: 0 = AEIOUHWY, 1 = BFPV, 2 = CGJKQSXZ, 3 = DT, 4 = L, 5 = MN, 6 = R
 		// H/W/Y don't get codes but don't break sequences either
 		// Initialize lastCode to a value that won't match any real code
 		var lastCode = '\0';
-		
+
 		for (var i = 1; i < upper.length() && result.length() < 4; i++) {
 			var c = upper.charAt(i);
 			var code = getSoundexCode(c);
@@ -5349,12 +5416,12 @@ public class StringUtils {
 			}
 			// If code == lastCode, skip it (consecutive same codes)
 		}
-		
+
 		// Pad with zeros if needed
 		while (result.length() < 4) {
 			result.append('0');
 		}
-		
+
 		return result.toString();
 	}
 
@@ -5399,15 +5466,15 @@ public class StringUtils {
 	public static String metaphone(String str) {
 		if (isEmpty(str))
 			return null;
-		
+
 		var upper = str.toUpperCase().replaceAll("[^A-Z]", "");
 		if (upper.isEmpty())
 			return "";
-		
+
 		var result = new StringBuilder();
 		var i = 0;
 		var len = upper.length();
-		
+
 		// Handle initial characters
 		if (upper.startsWith("KN") || upper.startsWith("GN") || upper.startsWith("PN") || upper.startsWith("AE") || upper.startsWith("WR")) {
 			i = 1;
@@ -5418,20 +5485,20 @@ public class StringUtils {
 			result.append('W');
 			i = 2;
 		}
-		
+
 		// Process remaining characters
 		while (i < len && result.length() < 4) {
 			var c = upper.charAt(i);
 			var prev = i > 0 ? upper.charAt(i - 1) : '\0';
 			var next = i < len - 1 ? upper.charAt(i + 1) : '\0';
 			var next2 = i < len - 2 ? upper.charAt(i + 2) : '\0';
-			
+
 			// Skip duplicates (except C)
 			if (c == prev && c != 'C') {
 				i++;
 				continue;
 			}
-			
+
 			switch (c) {
 				case 'B':
 					if (prev != 'M' || next != '\0')
@@ -5478,7 +5545,7 @@ public class StringUtils {
 					}
 					break;
 				case 'H':
-					if (!isVowel(prev) || !isVowel(next))
+					if (! isVowel(prev) || ! isVowel(next))
 						result.append('H');
 					break;
 				case 'K':
@@ -5534,10 +5601,12 @@ public class StringUtils {
 				case 'Z':
 					result.append('S');
 					break;
+				default:
+					break;
 			}
 			i++;
 		}
-		
+
 		return result.length() > 0 ? result.toString() : upper.substring(0, Math.min(1, upper.length()));
 	}
 
@@ -5567,17 +5636,17 @@ public class StringUtils {
 	public static String[] doubleMetaphone(String str) {
 		if (isEmpty(str))
 			return null;
-		
+
 		// For simplicity, return the same code for both primary and alternate
 		// A full Double Metaphone implementation would be much more complex
 		var primary = metaphone(str);
 		if (primary == null)
 			return null;
-		
+
 		// Generate alternate code (simplified - full implementation would have different rules)
 		var alternate = primary;
-		
-		return new String[]{primary, alternate};
+
+		return new String[] { primary, alternate };
 	}
 
 	/**
@@ -5620,10 +5689,10 @@ public class StringUtils {
 	public static String removeAccents(String str) {
 		if (str == null)
 			return null;
-		
+
 		// Normalize to NFD (decomposed form)
 		var normalized = Normalizer.normalize(str, Normalizer.Form.NFD);
-		
+
 		// Remove combining diacritical marks (Unicode category Mn)
 		var sb = new StringBuilder(normalized.length());
 		for (var i = 0; i < normalized.length(); i++) {
@@ -5634,7 +5703,7 @@ public class StringUtils {
 				sb.append(c);
 			}
 		}
-		
+
 		return sb.toString();
 	}
 
@@ -6988,9 +7057,7 @@ public class StringUtils {
 			return null;  // NOSONAR - Intentional.
 		if (predicate == null)
 			return new String[0];
-		return Arrays.stream(array)
-			.filter(predicate)
-			.toArray(String[]::new);
+		return Arrays.stream(array).filter(predicate).toArray(String[]::new);
 	}
 
 	/**
@@ -7014,14 +7081,12 @@ public class StringUtils {
 	 * @param mapper The function to apply to each element. Can be <jk>null</jk>.
 	 * @return A new array with the mapped elements, or <jk>null</jk> if the array was <jk>null</jk>.
 	 */
-	public static String[] mapped(String[] array, Function<String, String> mapper) {
+	public static String[] mapped(String[] array, Function<String,String> mapper) {
 		if (array == null)
 			return null;  // NOSONAR - Intentional.
 		if (mapper == null)
 			return Arrays.copyOf(array, array.length);
-		return Arrays.stream(array)
-			.map(mapper)
-			.toArray(String[]::new);
+		return Arrays.stream(array).map(mapper).toArray(String[]::new);
 	}
 
 	/**
@@ -7044,9 +7109,7 @@ public class StringUtils {
 	public static String[] distinct(String[] array) {
 		if (array == null)
 			return null;  // NOSONAR - Intentional.
-		return Arrays.stream(array)
-			.collect(Collectors.toCollection(LinkedHashSet::new))
-			.toArray(new String[0]);
+		return Arrays.stream(array).collect(Collectors.toCollection(LinkedHashSet::new)).toArray(new String[0]);
 	}
 
 	/**
@@ -7365,7 +7428,7 @@ public class StringUtils {
 		}
 
 		// Suggest interning for medium-length strings that might be repeated
-		if (length > 10 && length < 100 && !isInterned(str)) {
+		if (length > 10 && length < 100 && ! isInterned(str)) {
 			suggestions.add("Consider interning if this string is used frequently");
 		}
 
diff --git a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/Utils.java b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/Utils.java
index c2d9646942..71eee98710 100644
--- a/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/Utils.java
+++ b/juneau-core/juneau-common/src/main/java/org/apache/juneau/common/utils/Utils.java
@@ -406,6 +406,59 @@ public class Utils {
 		return () -> mformat(pattern, args);
 	}
 
+	/**
+	 * Shortcut for calling {@link StringUtils#format(String, Object...)}.
+	 *
+	 * <p>
+	 * This method provides a convenient shorthand for printf-style string formatting.
+	 *
+	 * <h5 class='section'>Examples:</h5>
+	 * <p class='bjava'>
+	 * 	<jc>// Basic formatting</jc>
+	 * 	f(<js>"Hello %s, you have %d items"</js>, <js>"John"</js>, 5);
+	 * 	<jc>// Returns: "Hello John, you have 5 items"</jc>
+	 *
+	 * 	<jc>// Floating point</jc>
+	 * 	f(<js>"Price: $%.2f"</js>, 19.99);
+	 * 	<jc>// Returns: "Price: $19.99"</jc>
+	 * </p>
+	 *
+	 * @param pattern The printf-style format string.
+	 * @param args The arguments to format.
+	 * @return The formatted string.
+	 * @see StringUtils#format(String, Object...)
+	 * @see #mf(String, Object...) for MessageFormat-style formatting
+	 */
+	public static String f(String pattern, Object...args) {
+		return format(pattern, args);
+	}
+
+	/**
+	 * Creates a formatted string supplier with printf-style format arguments for lazy evaluation.
+	 *
+	 * <p>This method returns a {@link Supplier} that formats the string pattern with the provided arguments
+	 * only when the supplier's {@code get()} method is called. This is useful for expensive string formatting
+	 * operations that may not always be needed, such as error messages in assertions.</p>
+	 *
+	 * <h5 class='section'>Usage Examples:</h5>
+	 * <p class='bjava'>
+	 * 	<jc>// Lazy evaluation - string is only formatted if assertion fails</jc>
+	 * 	assertTrue(condition, fs(<js>"Expected %s but got %s"</js>, expected, actual));
+	 *
+	 * 	<jc>// Can be used anywhere a Supplier&lt;String&gt; is expected</jc>
+	 * 	Supplier&lt;String&gt; <jv>messageSupplier</jv> = fs(<js>"Processing item %d of %d"</js>, i, total);
+	 * </p>
+	 *
+	 * @param pattern The printf-style format string using <js>%s</js>, <js>%d</js>, etc. placeholders.
+	 * @param args The arguments to substitute into the pattern placeholders.
+	 * @return A {@link Supplier} that will format the string when {@code get()} is called.
+	 * @see StringUtils#format(String, Object...)
+	 * @see #mfs(String, Object...) for MessageFormat-style formatting
+	 */
+	public static Supplier<String> fs(String pattern, Object...args) {
+		return () -> format(pattern, args);
+	}
+
 	/**
 	 * Calculates a hash code for the specified values.
 	 *
diff --git a/juneau-utest/src/test/java/org/apache/juneau/common/utils/FormatString_Test.java b/juneau-utest/src/test/java/org/apache/juneau/common/utils/FormatString_Test.java
new file mode 100644
index 0000000000..a8a4445544
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/common/utils/FormatString_Test.java
@@ -0,0 +1,416 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.common.utils;
+
+import static org.apache.juneau.common.utils.Utils.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static java.util.stream.Collectors.*;
+
+import java.math.*;
+import java.text.*;
+import java.util.*;
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.common.function.*;
+import org.junit.jupiter.api.*;
+
+class FormatString_Test extends TestBase {
+
+	private static StringFormat fs(String pattern) {
+		return StringFormat.of(pattern);
+	}
+
+	private static String stringify(ThrowingSupplier<String> supplier) {
+		try {
+			return supplier.get();
+		} catch (Throwable t) {
+			return t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+	}
+
+	private static void assertStringFormat(String pattern, Locale locale, Object... args) {
+		var expected = stringify(()->String.format(locale, pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertStringFormat(String pattern, Object... args) {
+		var expected = stringify(()->String.format(pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMessageFormat(String pattern, Locale locale, Object... args) {
+		var expected = stringify(()->new MessageFormat(pattern, locale).format(args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMessageFormat(String pattern, Object... args) {
+		var expected = stringify(()->MessageFormat.format(pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMixedFormat(String expected, String pattern, Locale locale, Object... args) {
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMixedFormat(String expected, String pattern, Object... args) {
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	//====================================================================================================
+	// MessageFormat tests
+	//====================================================================================================
+	@Test void a01_messageFormat() {
+		assertMessageFormat("Hello {0}", "John");
+		assertMessageFormat("Price: {0,number,currency}", 19.99);
+		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5, 3);
+		assertMessageFormat("Hello {0} world", "John");
+		assertMessageFormat("Count: {0,number,integer}", 1234);
+		assertMessageFormat("Date: {0,date,short}", new Date(0));
+		assertMessageFormat("Time: {0,time,short}", new Date(0));
+		// Simple {0} with Date - uses DATE_FORMAT_CACHE for formatting
+		assertMessageFormat("Date: {0}", new Date(0));
+		assertMessageFormat("Value: {0}", (String)null);
+		assertMessageFormat("Name: {0}", "");
+		assertMessageFormat("Text: {0}\nNewline\tTab", "Hello");
+		assertMessageFormat("Unicode: {0} 中文", "Test");
+		assertMessageFormat("{0}{1}", "A", "B");
+		assertMessageFormat("{0} and {0} again", "Hello");
+		assertMessageFormat("Price: {0,number,currency}, Count: {1,number,integer}, Date: {2,date,short}", 19.99, 42, new java.util.Date());
+		assertMessageFormat("Price: {0,number,currency}", Locale.US, 19.99);
+		assertMessageFormat("Price: {0,number,currency}", Locale.FRANCE, 19.99);
+		assertMessageFormat("a '{0}' b");
+		assertMessageFormat("a ''{0}'' b", 1);
+		assertMessageFormat("'{0}'");
+		assertMessageFormat("''{0}''", 1);
+
+		// Errors
+		assertMessageFormat("Set: {{0}}", 50);
+		assertMessageFormat("Set: {{0}} and {{1}}", "A", "B");
+		assertMessageFormat("Hello {0}");
+		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5);
+		assertMessageFormat("Hello {");
+		assertMessageFormat("Hello {0");
+		assertMessageFormat("Hello '");
+		assertMessageFormat("Hello 'x");
+	}
+
+	//====================================================================================================
+	// StringFormat (printf) tests
+	//====================================================================================================
+	@Test void a02_stringFormat() {
+		assertStringFormat("Hello %s", "John");
+		assertStringFormat("Price: $%.2f", 19.99);
+		assertStringFormat("Name: %-10s Age: %3d", "John", 25);
+		assertStringFormat("Color: #%06X", 0xFF5733);
+		assertStringFormat("Hello world");
+		assertStringFormat("Progress: %d%%", 50);
+		assertStringFormat("");
+		assertStringFormat("%1$s loves %2$s, and %1$s also loves %3$s", "Alice", "Bob", "Charlie");
+		assertStringFormat("Hello %1$s", "John");
+		assertStringFormat("Price: %1$.2f", 19.99);
+		assertStringFormat("Octal: %o", 64);
+		assertStringFormat("Octal: %o", 255);
+		assertStringFormat("Octal: %o", (Number)null);
+		assertStringFormat("Flag: %b", true);
+		assertStringFormat("Flag: %b", false);
+		assertStringFormat("Flag: %b", (Boolean)null);
+		// %B uppercase boolean formatting
+		assertStringFormat("Flag: %B", true);
+		assertStringFormat("Flag: %B", false);
+		assertStringFormat("Flag: %B", (Boolean)null);  // Line 281: null -> "FALSE"
+		assertStringFormat("Flag: %B", "hello");  // Line 285: non-Boolean -> "TRUE"
+		assertStringFormat("Flag: %B", 42);  // Line 285: non-Boolean -> "TRUE"
+		assertStringFormat("Char: %c", 'A');
+		assertStringFormat("Char: %c", "A");
+		assertStringFormat("Char: %c", (String)null);
+		assertStringFormat("Value: %.2e", 1234567.0);
+		assertStringFormat("Value: %.2e", (Number)null);
+		assertStringFormat("Number: %+10.2f", 19.99);
+		assertStringFormat("ID: %05d", 42);
+		assertStringFormat("Value: %d", (Number)null);  // Line 304: null -> "null"
+		assertStringFormat("Value: %s", (String)null);
+		assertStringFormat("Name: %s", "");
+		assertStringFormat("%s%s", "A", "B");
+		assertStringFormat("Progress: %d%% Complete: %d%%", 50, 75);
+		assertStringFormat("%1$s and %1$s again", "Hello");
+		assertStringFormat("Hex: 0x%08X, Decimal: %+d, Float: %10.3f", 255, 42, 3.14159);
+		assertStringFormat("Price: %.2f", Locale.US, 19.99);
+		assertStringFormat("Price: %.2f", Locale.FRANCE, 19.99);
+		assertStringFormat("Value: %s", (Object)null);
+		assertStringFormat("Int: %d", 42);
+		assertStringFormat("Long: %d", 1234567890L);
+		assertStringFormat("Byte: %d", (byte)127);
+		assertStringFormat("Short: %d", (short)32767);
+		assertStringFormat("Int: %d", Locale.FRANCE, 1234);
+		assertStringFormat("Long: %d", Locale.GERMANY, 1234567L);
+		assertStringFormat("Value: %d", "not-a-number");
+		assertStringFormat("Hex: %x", 255);  // Line 328: Integer -> Integer.toHexString()
+		assertStringFormat("Hex: %x", 255L);
+		assertStringFormat("Hex: %x", (byte)255);
+		assertStringFormat("Hex: %x", (Number)null);  // Line 324: null -> "null"
+		assertStringFormat("Hex: %X", 255);
+		assertStringFormat("Hex: %X", 0xABCL);
+		assertStringFormat("Hex: %X", (short)255);
+		assertStringFormat("Hex: %X", (Number)null);  // Line 337: null -> "null"
+		assertStringFormat("Octal: %o", 255L);
+		assertStringFormat("Octal: %o", (byte)64);
+		assertStringFormat("Value: %b", "hello");
+		assertStringFormat("Value: %b", 42);
+		assertStringFormat("Char: %c", 65);
+		assertStringFormat("Char: %c", 65L);
+		assertStringFormat("Char: %c", "X");
+		assertStringFormat("Char: %C", (Character)null);  // Line 376: null -> "null"
+		assertStringFormat("Char: %C", 66);  // Line 382-383: Number (Integer) -> Character.toUpperCase((char)o2.intValue())
+		assertStringFormat("Char: %C", 66L);  // Line 382-383: Number (Long) -> Character.toUpperCase((char)o2.intValue())
+		assertStringFormat("Float: %f", 3.14f);
+		assertStringFormat("Double: %f", 3.14159);
+		assertStringFormat("Float: %f", Locale.FRANCE, 3.14f);
+		assertStringFormat("Double: %f", Locale.GERMANY, 1234.56);
+		assertStringFormat("Value: %f", (Number)null);  // Line 389: null -> "null"
+		assertStringFormat("Value: %f", "not-a-number");
+		assertStringFormat("Value: %.2e", 1234.56);
+		assertStringFormat("Value: %S", "hello");
+		assertStringFormat("Value: %S", (String)null);  // Line 297: null -> "null"
+		assertStringFormat("Value: %B", true);
+		assertStringFormat("Char: %C", 'a');
+		assertStringFormat("Float: %F", 3.14);
+
+		// Errors
+		assertStringFormat("Hello %s");
+		assertStringFormat("Hello %s and %s", "John");
+		assertStringFormat("Hello %");
+		assertStringFormat("Hello %s and %", "John");
+		assertStringFormat("Hello %x$s", "John");
+	}
+
+	//====================================================================================================
+	// Mixed format tests
+	//====================================================================================================
+	@Test void a03_mixedFormat() {
+		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
+		assertMixedFormat("User Alice has admin and 10 items", "User {0} has %s and {2} items", "Alice", "admin", 10);
+		assertMixedFormat("Alice loves Bob, and Alice also loves Charlie", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob", "Charlie");
+		assertMixedFormat("Alice has 5 items, Bob has 3 items, total: 8", "{0} has %d items, {2} has %d items, total: %d", "Alice", 5, "Bob", 3, 8);
+		assertMixedFormat("Alice Bob Charlie", "{0} %2$s {2}", "Alice", "Bob", "Charlie");
+		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
+		assertMixedFormat("A B B D C", "{0} %s {1} %s {2}", "A", "B", "C", "D");
+		assertMixedFormat("ABB", "{0}%s{1}", "A", "B", "C");
+		assertMixedFormat("Hello and Hello are the same", "{0} and %1$s are the same", "Hello");
+
+		// Errors
+		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "Hello {0} and %s", "John");
+		assertMixedFormat("John has 5 items and {2} friends", "{0} has %d items and {2} friends", "John", 5);
+		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob");
+	}
+
+	//====================================================================================================
+	// Supported but deviates from MessageFormat/String.format
+	//====================================================================================================
+	@Test void a04_supportedButDeviatesFromMessageFormat() {
+		// {} is not supported by MessageFormat, only by StringFormat as an extension
+		assertMixedFormat("Hello John world", "Hello {} world", "John");
+		assertMixedFormat("A B C", "{} {} {}", "A", "B", "C");
+		// BigDecimal with %d - String.format throws exception, but our optimized code handles it
+		assertMixedFormat("Number: 42", "Number: %d", new BigDecimal("42"));
+		// MessageFormat throws NullPointerException when locale is null, but StringFormat handles it
+		// So we test StringFormat's behavior directly instead of comparing with MessageFormat
+		assertMixedFormat("Price: $19.99", "Price: {0,number,currency}", (Locale)null, 19.99);
+	}
+
+	//====================================================================================================
+	// Error handling
+	//====================================================================================================
+	@Test void a05_errors() {
+		assertThrows(IllegalArgumentException.class, () -> new StringFormat(null));
+		assertThrows(IllegalArgumentException.class, () -> fs(null));
+	}
+
+	@Test void a06_caching() {
+		// Should return the same instance due to caching
+		assertSame(fs("Hello {0}"), fs("Hello {0}"));
+
+		// Different patterns should return different instances
+		assertNotSame(fs("Hello {0}"), fs("Hello %s"));
+
+		// Constructor doesn't use cache, so instances should be different
+		var fmt1 = new StringFormat("Hello {0}");
+		var fmt2 = new StringFormat("Hello {0}");
+		assertNotSame(fmt1, fmt2);
+		assertEquals(fmt1, fmt2); // But they should be equal
+	}
+
+	@Test void a07_equalsAndHashCode() {
+		var fmt1 = StringFormat.of("Hello {0}");
+		var fmt2 = StringFormat.of("Hello {0}");
+		var fmt3 = StringFormat.of("Hello %s");
+
+		assertEquals(fmt1, fmt2);
+		assertNotEquals(fmt1, fmt3);
+		assertEquals(fmt1.hashCode(), fmt2.hashCode());
+	}
+
+	@Test void a08_toString() {
+		assertEquals("Hello {0}", fs("Hello {0}").toString());
+	}
+
+	@Test void a09_toPattern() {
+		// Literal tokens
+		assertEquals("[L:Hello ]", fs("Hello ").toPattern());
+		assertEquals("[L:a ][L:{0}][L: b]", fs("a '{0}' b").toPattern());  // Single quotes don't escape MessageFormat
+
+		// MessageFormat tokens - simple (content == null) - Line 228: content == null branch
+		assertEquals("[L:Hello ][M:s0]", fs("Hello {0}").toPattern());
+		assertEquals("[L:Hello ][M:s0][L: ][M:s1]", fs("Hello {0} {1}").toPattern());
+
+		// MessageFormat tokens - complex (content != null) - Line 228: content != null branch
+		assertEquals("[L:Price: ][M:o0:{0,number,currency}]", fs("Price: {0,number,currency}").toPattern());
+		assertEquals("[L:Count: ][M:o0:{0,number,integer}]", fs("Count: {0,number,integer}").toPattern());
+		assertEquals("[L:Date: ][M:o0:{0,date,short}]", fs("Date: {0,date,short}").toPattern());
+
+		// StringFormat tokens - Line 406: StringFormatToken.toString()
+		assertEquals("[L:Hello ][S:s0:%s]", fs("Hello %s").toPattern());  // Simple format: 's'
+		assertEquals("[L:Number: ][S:d0:%d]", fs("Number: %d").toPattern());  // Simple format: 'd'
+		assertEquals("[L:Hex: ][S:x0:%x]", fs("Hex: %x").toPattern());  // Simple format: 'x'
+		assertEquals("[L:Float: ][S:z0:%.2f]", fs("Float: %.2f").toPattern());  // Complex format: 'z' (other)
+		assertEquals("[L:ID: ][S:z0:%05d]", fs("ID: %05d").toPattern());  // Complex format: 'z' (other)
+
+		// Mixed formats
+		assertEquals("[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]", fs("Hello {0}, you have %d items").toPattern());
+		assertEquals("[L:Price: ][M:o0:{0,number,currency}][L: and ][S:s1:%s]", fs("Price: {0,number,currency} and %s").toPattern());
+
+		// Time conversions (2-character) - Line 529: 't' or 'T' handling
+		assertEquals("[L:Month: ][S:z0:%tm]", fs("Month: %tm").toPattern());  // %tm is 2-character time conversion
+		assertEquals("[L:Year: ][S:z0:%tY]", fs("Year: %tY").toPattern());  // %tY is 2-character time conversion
+		assertEquals("[L:Date: ][S:z0:%TD]", fs("Date: %TD").toPattern());  // %TD is 2-character time conversion
+	}
+
+	@Test void a10_veryLongPattern() {
+		var pattern = "Start: " + IntStream.range(0, 10).mapToObj(i -> "{" + i + "}").collect(joining(" ")) + " ";
+		var args = IntStream.range(0, 10).boxed().toArray();
+		assertMessageFormat(pattern, args);
+	}
+
+	@Test void a11_parseIndexErrors() {
+		assertThrows(IllegalArgumentException.class, () -> fs("Hello {abc}"));
+	}
+
+	@Test void a12_localeHandling() {
+		// Lines 259-260: Test locale null checks and default locale detection in StringFormatToken
+		// Line 259: var l = locale == null ? Locale.getDefault() : locale;
+		// Line 260: var dl = locale == null || locale.equals(Locale.getDefault());
+
+		// Test with null locale (covers locale == null on both lines)
+		assertStringFormat("Hello %s", (Locale)null, "John");
+		assertStringFormat("Number: %d", (Locale)null, 42);
+		assertStringFormat("Float: %.2f", (Locale)null, 3.14);  // Use .2f for consistent formatting
+
+		// Test with default locale (covers locale.equals(Locale.getDefault()) on line 260)
+		assertStringFormat("Hello %s", Locale.getDefault(), "John");
+		assertStringFormat("Number: %d", Locale.getDefault(), 42);
+		assertStringFormat("Float: %.2f", Locale.getDefault(), 3.14);  // Use .2f for consistent formatting
+
+		// Test with non-default locale (covers else branch on line 259 and false case on line 260)
+		assertStringFormat("Hello %s", Locale.FRANCE, "John");
+		assertStringFormat("Number: %d", Locale.GERMANY, 42);
+		assertStringFormat("Float: %.2f", Locale.JAPAN, 3.14);  // Use .2f for consistent formatting
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringFormat_Test.java b/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringFormat_Test.java
new file mode 100644
index 0000000000..ea6ec942d8
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringFormat_Test.java
@@ -0,0 +1,416 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.common.utils;
+
+import static org.apache.juneau.common.utils.Utils.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static java.util.stream.Collectors.*;
+
+import java.math.*;
+import java.text.*;
+import java.util.*;
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.common.function.*;
+import org.junit.jupiter.api.*;
+
+class StringFormat_Test extends TestBase {
+
+	private static StringFormat fs(String pattern) {
+		return StringFormat.of(pattern);
+	}
+
+	private static String stringify(ThrowingSupplier<String> supplier) {
+		try {
+			return supplier.get();
+		} catch (Throwable t) {
+			return t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+	}
+
+	private static void assertStringFormat(String pattern, Locale locale, Object... args) {
+		var expected = stringify(()->String.format(locale, pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertStringFormat(String pattern, Object... args) {
+		var expected = stringify(()->String.format(pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMessageFormat(String pattern, Locale locale, Object... args) {
+		var expected = stringify(()->new MessageFormat(pattern, locale).format(args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMessageFormat(String pattern, Object... args) {
+		var expected = stringify(()->MessageFormat.format(pattern, args));
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMixedFormat(String expected, String pattern, Locale locale, Object... args) {
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(locale, args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	private static void assertMixedFormat(String expected, String pattern, Object... args) {
+		var actual = "";
+		var fmt = (StringFormat)null;
+		try {
+			var fmt2 = fs(pattern);
+			fmt = fmt2;
+			actual = stringify(()->fmt2.format(args));
+		} catch (Throwable t) {
+			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
+		}
+		if (!expected.equals(actual)) {
+			System.out.println("Pattern: " + pattern);
+			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
+			System.out.println("toPattern(): " + toPattern);
+			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
+		}
+	}
+
+	//====================================================================================================
+	// MessageFormat tests
+	//====================================================================================================
+	@Test void a01_messageFormat() {
+		assertMessageFormat("Hello {0}", "John");
+		assertMessageFormat("Price: {0,number,currency}", 19.99);
+		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5, 3);
+		assertMessageFormat("Hello {0} world", "John");
+		assertMessageFormat("Count: {0,number,integer}", 1234);
+		assertMessageFormat("Date: {0,date,short}", new Date(0));
+		assertMessageFormat("Time: {0,time,short}", new Date(0));
+		// Simple {0} with Date - uses DATE_FORMAT_CACHE for formatting
+		assertMessageFormat("Date: {0}", new Date(0));
+		assertMessageFormat("Value: {0}", (String)null);
+		assertMessageFormat("Name: {0}", "");
+		assertMessageFormat("Text: {0}\nNewline\tTab", "Hello");
+		assertMessageFormat("Unicode: {0} 中文", "Test");
+		assertMessageFormat("{0}{1}", "A", "B");
+		assertMessageFormat("{0} and {0} again", "Hello");
+		assertMessageFormat("Price: {0,number,currency}, Count: {1,number,integer}, Date: {2,date,short}", 19.99, 42, new java.util.Date());
+		assertMessageFormat("Price: {0,number,currency}", Locale.US, 19.99);
+		assertMessageFormat("Price: {0,number,currency}", Locale.FRANCE, 19.99);
+		assertMessageFormat("a '{0}' b");
+		assertMessageFormat("a ''{0}'' b", 1);
+		assertMessageFormat("'{0}'");
+		assertMessageFormat("''{0}''", 1);
+
+		// Errors
+		assertMessageFormat("Set: {{0}}", 50);
+		assertMessageFormat("Set: {{0}} and {{1}}", "A", "B");
+		assertMessageFormat("Hello {0}");
+		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5);
+		assertMessageFormat("Hello {");
+		assertMessageFormat("Hello {0");
+		assertMessageFormat("Hello '");
+		assertMessageFormat("Hello 'x");
+	}
+
+	//====================================================================================================
+	// StringFormat (printf) tests
+	//====================================================================================================
+	@Test void a02_stringFormat() {
+		assertStringFormat("Hello %s", "John");
+		assertStringFormat("Price: $%.2f", 19.99);
+		assertStringFormat("Name: %-10s Age: %3d", "John", 25);
+		assertStringFormat("Color: #%06X", 0xFF5733);
+		assertStringFormat("Hello world");
+		assertStringFormat("Progress: %d%%", 50);
+		assertStringFormat("");
+		assertStringFormat("%1$s loves %2$s, and %1$s also loves %3$s", "Alice", "Bob", "Charlie");
+		assertStringFormat("Hello %1$s", "John");
+		assertStringFormat("Price: %1$.2f", 19.99);
+		assertStringFormat("Octal: %o", 64);
+		assertStringFormat("Octal: %o", 255);
+		assertStringFormat("Octal: %o", (Number)null);
+		assertStringFormat("Flag: %b", true);
+		assertStringFormat("Flag: %b", false);
+		assertStringFormat("Flag: %b", (Boolean)null);
+		// %B uppercase boolean formatting
+		assertStringFormat("Flag: %B", true);
+		assertStringFormat("Flag: %B", false);
+		assertStringFormat("Flag: %B", (Boolean)null);  // Line 281: null -> "FALSE"
+		assertStringFormat("Flag: %B", "hello");  // Line 285: non-Boolean -> "TRUE"
+		assertStringFormat("Flag: %B", 42);  // Line 285: non-Boolean -> "TRUE"
+		assertStringFormat("Char: %c", 'A');
+		assertStringFormat("Char: %c", "A");
+		assertStringFormat("Char: %c", (String)null);
+		assertStringFormat("Value: %.2e", 1234567.0);
+		assertStringFormat("Value: %.2e", (Number)null);
+		assertStringFormat("Number: %+10.2f", 19.99);
+		assertStringFormat("ID: %05d", 42);
+		assertStringFormat("Value: %d", (Number)null);  // Line 304: null -> "null"
+		assertStringFormat("Value: %s", (String)null);
+		assertStringFormat("Name: %s", "");
+		assertStringFormat("%s%s", "A", "B");
+		assertStringFormat("Progress: %d%% Complete: %d%%", 50, 75);
+		assertStringFormat("%1$s and %1$s again", "Hello");
+		assertStringFormat("Hex: 0x%08X, Decimal: %+d, Float: %10.3f", 255, 42, 3.14159);
+		assertStringFormat("Price: %.2f", Locale.US, 19.99);
+		assertStringFormat("Price: %.2f", Locale.FRANCE, 19.99);
+		assertStringFormat("Value: %s", (Object)null);
+		assertStringFormat("Int: %d", 42);
+		assertStringFormat("Long: %d", 1234567890L);
+		assertStringFormat("Byte: %d", (byte)127);
+		assertStringFormat("Short: %d", (short)32767);
+		assertStringFormat("Int: %d", Locale.FRANCE, 1234);
+		assertStringFormat("Long: %d", Locale.GERMANY, 1234567L);
+		assertStringFormat("Value: %d", "not-a-number");
+		assertStringFormat("Hex: %x", 255);  // Line 328: Integer -> Integer.toHexString()
+		assertStringFormat("Hex: %x", 255L);
+		assertStringFormat("Hex: %x", (byte)255);
+		assertStringFormat("Hex: %x", (Number)null);  // Line 324: null -> "null"
+		assertStringFormat("Hex: %X", 255);
+		assertStringFormat("Hex: %X", 0xABCL);
+		assertStringFormat("Hex: %X", (short)255);
+		assertStringFormat("Hex: %X", (Number)null);  // Line 337: null -> "null"
+		assertStringFormat("Octal: %o", 255L);
+		assertStringFormat("Octal: %o", (byte)64);
+		assertStringFormat("Value: %b", "hello");
+		assertStringFormat("Value: %b", 42);
+		assertStringFormat("Char: %c", 65);
+		assertStringFormat("Char: %c", 65L);
+		assertStringFormat("Char: %c", "X");
+		assertStringFormat("Char: %C", (Character)null);  // Line 376: null -> "null"
+		assertStringFormat("Char: %C", 66);  // Line 382-383: Number (Integer) -> Character.toUpperCase((char)o2.intValue())
+		assertStringFormat("Char: %C", 66L);  // Line 382-383: Number (Long) -> Character.toUpperCase((char)o2.intValue())
+		assertStringFormat("Float: %f", 3.14f);
+		assertStringFormat("Double: %f", 3.14159);
+		assertStringFormat("Float: %f", Locale.FRANCE, 3.14f);
+		assertStringFormat("Double: %f", Locale.GERMANY, 1234.56);
+		assertStringFormat("Value: %f", (Number)null);  // Line 389: null -> "null"
+		assertStringFormat("Value: %f", "not-a-number");
+		assertStringFormat("Value: %.2e", 1234.56);
+		assertStringFormat("Value: %S", "hello");
+		assertStringFormat("Value: %S", (String)null);  // Line 297: null -> "null"
+		assertStringFormat("Value: %B", true);
+		assertStringFormat("Char: %C", 'a');
+		assertStringFormat("Float: %F", 3.14);
+
+		// Errors
+		assertStringFormat("Hello %s");
+		assertStringFormat("Hello %s and %s", "John");
+		assertStringFormat("Hello %");
+		assertStringFormat("Hello %s and %", "John");
+		assertStringFormat("Hello %x$s", "John");
+	}
+
+	//====================================================================================================
+	// Mixed format tests
+	//====================================================================================================
+	@Test void a03_mixedFormat() {
+		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
+		assertMixedFormat("User Alice has admin and 10 items", "User {0} has %s and {2} items", "Alice", "admin", 10);
+		assertMixedFormat("Alice loves Bob, and Alice also loves Charlie", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob", "Charlie");
+		assertMixedFormat("Alice has 5 items, Bob has 3 items, total: 8", "{0} has %d items, {2} has %d items, total: %d", "Alice", 5, "Bob", 3, 8);
+		assertMixedFormat("Alice Bob Charlie", "{0} %2$s {2}", "Alice", "Bob", "Charlie");
+		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
+		assertMixedFormat("A B B D C", "{0} %s {1} %s {2}", "A", "B", "C", "D");
+		assertMixedFormat("ABB", "{0}%s{1}", "A", "B", "C");
+		assertMixedFormat("Hello and Hello are the same", "{0} and %1$s are the same", "Hello");
+
+		// Errors
+		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "Hello {0} and %s", "John");
+		assertMixedFormat("John has 5 items and {2} friends", "{0} has %d items and {2} friends", "John", 5);
+		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob");
+	}
+
+	//====================================================================================================
+	// Supported but deviates from MessageFormat/String.format
+	//====================================================================================================
+	@Test void a04_supportedButDeviatesFromMessageFormat() {
+		// {} is not supported by MessageFormat, only by StringFormat as an extension
+		assertMixedFormat("Hello John world", "Hello {} world", "John");
+		assertMixedFormat("A B C", "{} {} {}", "A", "B", "C");
+		// BigDecimal with %d - String.format throws exception, but our optimized code handles it
+		assertMixedFormat("Number: 42", "Number: %d", new BigDecimal("42"));
+		// MessageFormat throws NullPointerException when locale is null, but StringFormat handles it
+		// So we test StringFormat's behavior directly instead of comparing with MessageFormat
+		assertMixedFormat("Price: $19.99", "Price: {0,number,currency}", (Locale)null, 19.99);
+	}
+
+	//====================================================================================================
+	// Error handling
+	//====================================================================================================
+	@Test void a05_errors() {
+		assertThrows(IllegalArgumentException.class, () -> new StringFormat(null));
+		assertThrows(IllegalArgumentException.class, () -> fs(null));
+	}
+
+	@Test void a06_caching() {
+		// Should return the same instance due to caching
+		assertSame(fs("Hello {0}"), fs("Hello {0}"));
+
+		// Different patterns should return different instances
+		assertNotSame(fs("Hello {0}"), fs("Hello %s"));
+
+		// Constructor doesn't use cache, so instances should be different
+		var fmt1 = new StringFormat("Hello {0}");
+		var fmt2 = new StringFormat("Hello {0}");
+		assertNotSame(fmt1, fmt2);
+		assertEquals(fmt1, fmt2); // But they should be equal
+	}
+
+	@Test void a07_equalsAndHashCode() {
+		var fmt1 = StringFormat.of("Hello {0}");
+		var fmt2 = StringFormat.of("Hello {0}");
+		var fmt3 = StringFormat.of("Hello %s");
+
+		assertEquals(fmt1, fmt2);
+		assertNotEquals(fmt1, fmt3);
+		assertEquals(fmt1.hashCode(), fmt2.hashCode());
+	}
+
+	@Test void a08_toString() {
+		assertEquals("Hello {0}", fs("Hello {0}").toString());
+	}
+
+	@Test void a09_toPattern() {
+		// Literal tokens
+		assertEquals("[L:Hello ]", fs("Hello ").toPattern());
+		assertEquals("[L:a ][L:{0}][L: b]", fs("a '{0}' b").toPattern());  // Single quotes don't escape MessageFormat
+
+		// MessageFormat tokens - simple (content == null) - Line 228: content == null branch
+		assertEquals("[L:Hello ][M:s0]", fs("Hello {0}").toPattern());
+		assertEquals("[L:Hello ][M:s0][L: ][M:s1]", fs("Hello {0} {1}").toPattern());
+
+		// MessageFormat tokens - complex (content != null) - Line 228: content != null branch
+		assertEquals("[L:Price: ][M:o0:{0,number,currency}]", fs("Price: {0,number,currency}").toPattern());
+		assertEquals("[L:Count: ][M:o0:{0,number,integer}]", fs("Count: {0,number,integer}").toPattern());
+		assertEquals("[L:Date: ][M:o0:{0,date,short}]", fs("Date: {0,date,short}").toPattern());
+
+		// StringFormat tokens - Line 406: StringFormatToken.toString()
+		assertEquals("[L:Hello ][S:s0:%s]", fs("Hello %s").toPattern());  // Simple format: 's'
+		assertEquals("[L:Number: ][S:d0:%d]", fs("Number: %d").toPattern());  // Simple format: 'd'
+		assertEquals("[L:Hex: ][S:x0:%x]", fs("Hex: %x").toPattern());  // Simple format: 'x'
+		assertEquals("[L:Float: ][S:z0:%.2f]", fs("Float: %.2f").toPattern());  // Complex format: 'z' (other)
+		assertEquals("[L:ID: ][S:z0:%05d]", fs("ID: %05d").toPattern());  // Complex format: 'z' (other)
+
+		// Mixed formats
+		assertEquals("[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]", fs("Hello {0}, you have %d items").toPattern());
+		assertEquals("[L:Price: ][M:o0:{0,number,currency}][L: and ][S:s1:%s]", fs("Price: {0,number,currency} and %s").toPattern());
+
+		// Time conversions (2-character) - Line 529: 't' or 'T' handling
+		assertEquals("[L:Month: ][S:z0:%tm]", fs("Month: %tm").toPattern());  // %tm is 2-character time conversion
+		assertEquals("[L:Year: ][S:z0:%tY]", fs("Year: %tY").toPattern());  // %tY is 2-character time conversion
+		assertEquals("[L:Date: ][S:z0:%TD]", fs("Date: %TD").toPattern());  // %TD is 2-character time conversion
+	}
+
+	@Test void a10_veryLongPattern() {
+		var pattern = "Start: " + IntStream.range(0, 10).mapToObj(i -> "{" + i + "}").collect(joining(" ")) + " ";
+		var args = IntStream.range(0, 10).boxed().toArray();
+		assertMessageFormat(pattern, args);
+	}
+
+	@Test void a11_parseIndexErrors() {
+		assertThrows(IllegalArgumentException.class, () -> fs("Hello {abc}"));
+	}
+
+	@Test void a12_localeHandling() {
+		// Lines 259-260: Test locale null checks and default locale detection in StringFormatToken
+		// Line 259: var l = locale == null ? Locale.getDefault() : locale;
+		// Line 260: var dl = locale == null || locale.equals(Locale.getDefault());
+
+		// Test with null locale (covers locale == null on both lines)
+		assertStringFormat("Hello %s", (Locale)null, "John");
+		assertStringFormat("Number: %d", (Locale)null, 42);
+		assertStringFormat("Float: %.2f", (Locale)null, 3.14);  // Use .2f for consistent formatting
+
+		// Test with default locale (covers locale.equals(Locale.getDefault()) on line 260)
+		assertStringFormat("Hello %s", Locale.getDefault(), "John");
+		assertStringFormat("Number: %d", Locale.getDefault(), 42);
+		assertStringFormat("Float: %.2f", Locale.getDefault(), 3.14);  // Use .2f for consistent formatting
+
+		// Test with non-default locale (covers else branch on line 259 and false case on line 260)
+		assertStringFormat("Hello %s", Locale.FRANCE, "John");
+		assertStringFormat("Number: %d", Locale.GERMANY, 42);
+		assertStringFormat("Float: %.2f", Locale.JAPAN, 3.14);  // Use .2f for consistent formatting
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringUtils_Test.java b/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringUtils_Test.java
index 355b73a196..538c864bd8 100755
--- a/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringUtils_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/common/utils/StringUtils_Test.java
@@ -39,7 +39,8 @@ class StringUtils_Test extends TestBase {
 	// isNumeric(String,Class)
 	// parseNumber(String,Class)
 	//====================================================================================================
-	@Test void a01_testParser() {
+	@Test
+	void a01_testParser() {
 
 		// Integers
 		assertTrue(isNumeric("123"));
@@ -144,9 +145,9 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isNumeric("0x123e1"));
 		assertEquals(0x123e1, parseNumber("0x123e1", null));
 
-		assertThrows(NumberFormatException.class, ()->parseNumber("x", Number.class));
-		assertThrows(NumberFormatException.class, ()->parseNumber("x", null));
-		assertThrowsWithMessage(NumberFormatException.class, "Unsupported Number type", ()->parseNumber("x", BadNumber.class));
+		assertThrows(NumberFormatException.class, () -> parseNumber("x", Number.class));
+		assertThrows(NumberFormatException.class, () -> parseNumber("x", null));
+		assertThrowsWithMessage(NumberFormatException.class, "Unsupported Number type", () -> parseNumber("x", BadNumber.class));
 	}
 
 	@SuppressWarnings("serial")
@@ -155,7 +156,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// test - Basic tests
 	//====================================================================================================
-	@Test void a02_numberRanges() {
+	@Test
+	void a02_numberRanges() {
 		// An integer range is -2,147,483,648 to 2,147,483,647
 
 		assertFalse(isNumeric(null));
@@ -246,17 +248,18 @@ class StringUtils_Test extends TestBase {
 
 		s = String.valueOf("214748364x");
 		assertFalse(isNumeric(s));
-		assertThrows(NumberFormatException.class, ()->parseNumber("214748364x", Number.class));
+		assertThrows(NumberFormatException.class, () -> parseNumber("214748364x", Number.class));
 
 		s = String.valueOf("2147483640x");
 		assertFalse(isNumeric(s));
-		assertThrows(NumberFormatException.class, ()->parseNumber("2147483640x", Long.class));
+		assertThrows(NumberFormatException.class, () -> parseNumber("2147483640x", Long.class));
 	}
 
 	//====================================================================================================
 	// testReplaceVars
 	//====================================================================================================
-	@Test void a03_replaceVars() throws Exception {
+	@Test
+	void a03_replaceVars() throws Exception {
 		var m = JsonMap.ofJson("{a:'A',b:1,c:true,d:'{e}',e:'E{f}E',f:'F',g:'{a}',h:'a',i:null}");
 
 		assertEquals("xxx", replaceVars("xxx", m));
@@ -291,39 +294,13 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// isFloat(String)
 	//====================================================================================================
-	@Test void a04_isFloat() {
-		var valid = a(
-			"+1.0",
-			"-1.0",
-			".0",
-			"NaN",
-			"Infinity",
-			"1e1",
-			"-1e-1",
-			"+1e+1",
-			"-1.1e-1",
-			"+1.1e+1",
-			"1.1f",
-			"1.1F",
-			"1.1d",
-			"1.1D",
-			"0x1.fffffffffffffp1023",
-			"0x1.FFFFFFFFFFFFFP1023"
-		);
+	@Test
+	void a04_isFloat() {
+		var valid = a("+1.0", "-1.0", ".0", "NaN", "Infinity", "1e1", "-1e-1", "+1e+1", "-1.1e-1", "+1.1e+1", "1.1f", "1.1F", "1.1d", "1.1D", "0x1.fffffffffffffp1023", "0x1.FFFFFFFFFFFFFP1023");
 		for (var s : valid)
 			assertTrue(isFloat(s));
 
-		var invalid = a(
-			null,
-			"",
-			"a",
-			"+",
-			"-",
-			".",
-			"a",
-			"+a",
-			"11a"
-		);
+		var invalid = a(null, "", "a", "+", "-", ".", "a", "+a", "11a");
 		for (var s : invalid)
 			assertFalse(isFloat(s));
 	}
@@ -331,35 +308,13 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// isDecimal(String)
 	//====================================================================================================
-	@Test void a05_isDecimal() {
-		var valid = a(
-			"+1",
-			"-1",
-			"0x123",
-			"0X123",
-			"0xdef",
-			"0XDEF",
-			"#def",
-			"#DEF",
-			"0123"
-		);
+	@Test
+	void a05_isDecimal() {
+		var valid = a("+1", "-1", "0x123", "0X123", "0xdef", "0XDEF", "#def", "#DEF", "0123");
 		for (var s : valid)
 			assertTrue(isDecimal(s));
 
-		var invalid = a(
-			null,
-			"",
-			"a",
-			"+",
-			"-",
-			".",
-			"0xdeg",
-			"0XDEG",
-			"#deg",
-			"#DEG",
-			"0128",
-			"012A"
-		);
+		var invalid = a(null, "", "a", "+", "-", ".", "0xdeg", "0XDEG", "#deg", "#DEG", "0128", "012A");
 		for (var s : invalid)
 			assertFalse(isDecimal(s));
 	}
@@ -372,34 +327,36 @@ class StringUtils_Test extends TestBase {
 	// join(int[],char)
 	// join(Collection,char)
 	//====================================================================================================
-	@Test void a01_join() {
+	@Test
+	void a01_join() {
 		assertNull(StringUtils.join((Object[])null, ","));
 		assertEquals("1", StringUtils.join(a(1), ","));
-		assertEquals("1,2", StringUtils.join(a(1,2), ","));
+		assertEquals("1,2", StringUtils.join(a(1, 2), ","));
 
 		assertNull(StringUtils.join((Collection<?>)null, ","));
 		assertEquals("1", StringUtils.join(l(a(1)), ","));
-		assertEquals("1,2", StringUtils.join(l(a(1,2)), ","));
+		assertEquals("1,2", StringUtils.join(l(a(1, 2)), ","));
 
 		assertNull(StringUtils.join((Object[])null, ','));
-		assertEquals("x,y,z", StringUtils.join(a("x,y","z"), ','));
+		assertEquals("x,y,z", StringUtils.join(a("x,y", "z"), ','));
 
 		assertNull(StringUtils.join((int[])null, ','));
 		assertEquals("1", StringUtils.join(ints(1), ','));
-		assertEquals("1,2", StringUtils.join(ints(1,2), ','));
+		assertEquals("1,2", StringUtils.join(ints(1, 2), ','));
 
 		assertNull(StringUtils.join((Collection<?>)null, ','));
 		assertEquals("1", StringUtils.join(l(a(1)), ','));
-		assertEquals("1,2", StringUtils.join(l(a(1,2)), ','));
+		assertEquals("1,2", StringUtils.join(l(a(1, 2)), ','));
 
 		assertNull(StringUtils.joine((List<?>)null, ','));
-		assertEquals("x\\,y,z", StringUtils.joine(l(a("x,y","z")), ','));
+		assertEquals("x\\,y,z", StringUtils.joine(l(a("x,y", "z")), ','));
 	}
 
 	//====================================================================================================
 	// split(String,char)
 	//====================================================================================================
-	@Test void a07_split() {
+	@Test
+	void a07_split() {
 		assertNull(StringUtils.splita((String)null));
 		assertEmpty(StringUtils.splita(""));
 		assertList(StringUtils.splita("1"), "1");
@@ -413,7 +370,8 @@ class StringUtils_Test extends TestBase {
 		assertList(StringUtils.splita("1,2\\\\,"), "1", "2\\", "");
 	}
 
-	@Test void a08_split2() {
+	@Test
+	void a08_split2() {
 		assertEmpty(split2test(null));
 		assertString("[]", split2test(""));
 		assertString("[1]", split2test("1"));
@@ -436,7 +394,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// split(String,char,int)
 	//====================================================================================================
-	@Test void a09_splitWithLimit() {
+	@Test
+	void a09_splitWithLimit() {
 		assertString("[boo,and,foo]", StringUtils.splita("boo:and:foo", ':', 10));
 		assertString("[boo,and:foo]", StringUtils.splita("boo:and:foo", ':', 2));
 		assertString("[boo:and:foo]", StringUtils.splita("boo:and:foo", ':', 1));
@@ -449,7 +408,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// nullIfEmpty(String)
 	//====================================================================================================
-	@Test void a10_nullIfEmpty() {
+	@Test
+	void a10_nullIfEmpty() {
 		assertNull(StringUtils.nullIfEmpty(null));
 		assertNull(StringUtils.nullIfEmpty(""));
 		assertNotNull(StringUtils.nullIfEmpty("x"));
@@ -458,7 +418,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// emptyIfNull(String)
 	//====================================================================================================
-	@Test void a11_emptyIfNull() {
+	@Test
+	void a11_emptyIfNull() {
 		assertEquals("", StringUtils.emptyIfNull(null));
 		assertEquals("", StringUtils.emptyIfNull(""));
 		assertEquals("x", StringUtils.emptyIfNull("x"));
@@ -469,7 +430,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// defaultIfEmpty(String, String)
 	//====================================================================================================
-	@Test void a12_defaultIfEmpty() {
+	@Test
+	void a12_defaultIfEmpty() {
 		assertEquals("default", StringUtils.defaultIfEmpty(null, "default"));
 		assertEquals("default", StringUtils.defaultIfEmpty("", "default"));
 		assertEquals("x", StringUtils.defaultIfEmpty("x", "default"));
@@ -479,7 +441,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("x", StringUtils.defaultIfEmpty("x", null)); // "x" is not empty, so return "x"
 	}
 
-	@Test void a13_defaultIfEmpty_withNullDefault() {
+	@Test
+	void a13_defaultIfEmpty_withNullDefault() {
 		assertNull(StringUtils.defaultIfEmpty(null, null));
 		assertNull(StringUtils.defaultIfEmpty("", null));
 		assertEquals("x", StringUtils.defaultIfEmpty("x", null));
@@ -488,7 +451,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// defaultIfBlank(String, String)
 	//====================================================================================================
-	@Test void a14_defaultIfBlank() {
+	@Test
+	void a14_defaultIfBlank() {
 		assertEquals("default", StringUtils.defaultIfBlank(null, "default"));
 		assertEquals("default", StringUtils.defaultIfBlank("", "default"));
 		assertEquals("default", StringUtils.defaultIfBlank("  ", "default"));
@@ -501,14 +465,16 @@ class StringUtils_Test extends TestBase {
 		assertEquals("x", StringUtils.defaultIfBlank("x", null)); // "x" is not blank, so return "x"
 	}
 
-	@Test void a15_defaultIfBlank_withNullDefault() {
+	@Test
+	void a15_defaultIfBlank_withNullDefault() {
 		assertNull(StringUtils.defaultIfBlank(null, null));
 		assertNull(StringUtils.defaultIfBlank("", null));
 		assertNull(StringUtils.defaultIfBlank("  ", null));
 		assertEquals("x", StringUtils.defaultIfBlank("x", null));
 	}
 
-	@Test void a16_defaultIfBlank_whitespaceOnly() {
+	@Test
+	void a16_defaultIfBlank_whitespaceOnly() {
 		assertEquals("default", StringUtils.defaultIfBlank(" ", "default"));
 		assertEquals("default", StringUtils.defaultIfBlank("\t\n\r", "default"));
 		// Note: \u00A0 (non-breaking space) may or may not be considered blank depending on Java version
@@ -521,7 +487,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toString(Object)
 	//====================================================================================================
-	@Test void a17_toString() {
+	@Test
+	void a17_toString() {
 		assertNull(StringUtils.toString(null));
 		assertEquals("hello", StringUtils.toString("hello"));
 		assertEquals("123", StringUtils.toString(123));
@@ -529,7 +496,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("1.5", StringUtils.toString(1.5));
 	}
 
-	@Test void a18_toString_withObjects() {
+	@Test
+	void a18_toString_withObjects() {
 		var list = List.of("a", "b", "c");
 		assertNotNull(StringUtils.toString(list));
 		assertTrue(StringUtils.toString(list).contains("a"));
@@ -539,7 +507,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(StringUtils.toString(map).contains("key"));
 	}
 
-	@Test void a19_toString_withCustomObject() {
+	@Test
+	void a19_toString_withCustomObject() {
 		var obj = new Object() {
 			@Override
 			public String toString() {
@@ -552,7 +521,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toString(Object, String)
 	//====================================================================================================
-	@Test void a20_toStringWithDefault() {
+	@Test
+	void a20_toStringWithDefault() {
 		assertEquals("default", StringUtils.toString(null, "default"));
 		assertEquals("hello", StringUtils.toString("hello", "default"));
 		assertEquals("123", StringUtils.toString(123, "default"));
@@ -560,17 +530,20 @@ class StringUtils_Test extends TestBase {
 		assertEquals("1.5", StringUtils.toString(1.5, "default"));
 	}
 
-	@Test void a21_toStringWithDefault_withNullDefault() {
+	@Test
+	void a21_toStringWithDefault_withNullDefault() {
 		assertNull(StringUtils.toString(null, null));
 		assertEquals("hello", StringUtils.toString("hello", null));
 	}
 
-	@Test void a22_toStringWithDefault_withEmptyDefault() {
+	@Test
+	void a22_toStringWithDefault_withEmptyDefault() {
 		assertEquals("", StringUtils.toString(null, ""));
 		assertEquals("hello", StringUtils.toString("hello", ""));
 	}
 
-	@Test void a23_toStringWithDefault_withObjects() {
+	@Test
+	void a23_toStringWithDefault_withObjects() {
 		var list = List.of("a", "b", "c");
 		var result = StringUtils.toString(list, "default");
 		assertNotNull(result);
@@ -579,7 +552,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("default", StringUtils.toString(null, "default"));
 	}
 
-	@Test void a24_toStringWithDefault_withCustomObject() {
+	@Test
+	void a24_toStringWithDefault_withCustomObject() {
 		var obj = new Object() {
 			@Override
 			public String toString() {
@@ -593,7 +567,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// unescapeChars(String,char[],char)
 	//====================================================================================================
-	@Test void a25_unescapeChars() {
+	@Test
+	void a25_unescapeChars() {
 		var escape = AsciiSet.of("\\,|");
 
 		assertNull(unEscapeChars(null, escape));
@@ -615,7 +590,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// decodeHex(String)
 	//====================================================================================================
-	@Test void a26_decodeHex() {
+	@Test
+	void a26_decodeHex() {
 		assertNull(decodeHex(null));
 		assertEquals("19azAZ", decodeHex("19azAZ"));
 		assertEquals("[0][1][ffff]", decodeHex("\u0000\u0001\uFFFF"));
@@ -624,7 +600,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// startsWith(String,char)
 	//====================================================================================================
-	@Test void a27_startsWith() {
+	@Test
+	void a27_startsWith() {
 		assertFalse(startsWith(null, 'a'));
 		assertFalse(startsWith("", 'a'));
 		assertTrue(startsWith("a", 'a'));
@@ -634,7 +611,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// endsWith(String,char)
 	//====================================================================================================
-	@Test void a28_endsWith() {
+	@Test
+	void a28_endsWith() {
 		assertFalse(endsWith(null, 'a'));
 		assertFalse(endsWith("", 'a'));
 		assertTrue(endsWith("a", 'a'));
@@ -645,19 +623,21 @@ class StringUtils_Test extends TestBase {
 	// base64EncodeToString(String)
 	// base64DecodeToString(String)
 	//====================================================================================================
-	@Test void a29_base64EncodeToString() {
+	@Test
+	void a29_base64EncodeToString() {
 		assertNull(base64DecodeToString(base64EncodeToString(null)));
 		assertEquals("", base64DecodeToString(base64EncodeToString("")));
 		assertEquals("foobar", base64DecodeToString(base64EncodeToString("foobar")));
 		assertEquals("\u0000\uffff", base64DecodeToString(base64EncodeToString("\u0000\uffff")));
-		assertThrowsWithMessage(IllegalArgumentException.class, "Invalid BASE64 string length.  Must be multiple of 4.", ()->base64Decode("a"));
-		assertThrows(IllegalArgumentException.class, ()->base64Decode("aaa"));
+		assertThrowsWithMessage(IllegalArgumentException.class, "Invalid BASE64 string length.  Must be multiple of 4.", () -> base64Decode("a"));
+		assertThrows(IllegalArgumentException.class, () -> base64Decode("aaa"));
 	}
 
 	//====================================================================================================
 	// generateUUID(String)
 	//====================================================================================================
-	@Test void a30_generateUUID() {
+	@Test
+	void a30_generateUUID() {
 		for (var i = 0; i < 10; i++) {
 			var s = random(i);
 			assertEquals(i, s.length());
@@ -669,7 +649,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// trim(String)
 	//====================================================================================================
-	@Test void a31_trim() {
+	@Test
+	void a31_trim() {
 		assertNull(trim(null));
 		assertEquals("", trim(""));
 		assertEquals("", trim("  "));
@@ -678,7 +659,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// parseISO8601Date(String)
 	//====================================================================================================
-	@Test void a32_parseISO8601Date() throws Exception {
+	@Test
+	void a32_parseISO8601Date() throws Exception {
 		assertNull(parseIsoDate(null));
 		assertNull(parseIsoDate(""));
 
@@ -702,7 +684,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// parseMap(String,char,char,boolean)
 	//====================================================================================================
-	@Test void a33_splitMap() {
+	@Test
+	void a33_splitMap() {
 		assertString("{a=1}", StringUtils.splitMap("a=1", true));
 		assertString("{a=1,b=2}", StringUtils.splitMap("a=1,b=2", true));
 		assertString("{a=1,b=2}", StringUtils.splitMap(" a = 1 , b = 2 ", true));
@@ -718,7 +701,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// isAbsoluteUri(String)
 	//====================================================================================================
-	@Test void a10_isAbsoluteUri() {
+	@Test
+	void a10_isAbsoluteUri() {
 		assertFalse(isAbsoluteUri(null));
 		assertFalse(isAbsoluteUri(""));
 		assertTrue(isAbsoluteUri("http://foo"));
@@ -734,7 +718,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// getAuthorityUri(String)
 	//====================================================================================================
-	@Test void a21_getAuthorityUri() {
+	@Test
+	void a21_getAuthorityUri() {
 		assertEquals("http://foo", getAuthorityUri("http://foo"));
 		assertEquals("http://foo:123", getAuthorityUri("http://foo:123"));
 		assertEquals("http://foo:123", getAuthorityUri("http://foo:123/"));
@@ -744,7 +729,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// splitQuoted(String)
 	//====================================================================================================
-	@Test void a22_splitQuoted() {
+	@Test
+	void a22_splitQuoted() {
 		assertNull(StringUtils.splitQuoted(null));
 		assertEmpty(StringUtils.splitQuoted(""));
 		assertEmpty(StringUtils.splitQuoted(" \t "));
@@ -769,7 +755,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// firstNonWhitespaceChar(String)
 	//====================================================================================================
-	@Test void a23_firstNonWhitespaceChar() {
+	@Test
+	void a23_firstNonWhitespaceChar() {
 		assertEquals('f', firstNonWhitespaceChar("foo"));
 		assertEquals('f', firstNonWhitespaceChar(" foo"));
 		assertEquals('f', firstNonWhitespaceChar("\tfoo"));
@@ -782,7 +769,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// lastNonWhitespaceChar(String)
 	//====================================================================================================
-	@Test void a24_lastNonWhitespaceChar() {
+	@Test
+	void a24_lastNonWhitespaceChar() {
 		assertEquals('r', lastNonWhitespaceChar("bar"));
 		assertEquals('r', lastNonWhitespaceChar(" bar "));
 		assertEquals('r', lastNonWhitespaceChar("\tbar\t"));
@@ -795,7 +783,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// testIsJsonObject(Object)
 	//====================================================================================================
-	@Test void a25_isJsonObject() {
+	@Test
+	void a25_isJsonObject() {
 		assertTrue(isJsonObject("{foo:'bar'}", true));
 		assertTrue(isJsonObject(" { foo:'bar' } ", true));
 		assertFalse(isJsonObject(" { foo:'bar'  ", true));
@@ -806,7 +795,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// isJsonArray(Object)
 	//====================================================================================================
-	@Test void a26_isJsonArray() {
+	@Test
+	void a26_isJsonArray() {
 		assertTrue(isJsonArray("[123,'bar']", true));
 		assertTrue(isJsonArray(" [ 123,'bar' ] ", true));
 		assertFalse(isJsonArray(" [ 123,'bar'  ", true));
@@ -817,7 +807,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// addLineNumbers(String)
 	//====================================================================================================
-	@Test void a27_addLineNumbers() {
+	@Test
+	void a27_addLineNumbers() {
 		assertNull(getNumberedLines(null));
 		assertEquals("1: \n", getNumberedLines(""));
 		assertEquals("1: foo\n", getNumberedLines("foo"));
@@ -827,18 +818,20 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// compare(String,String)
 	//====================================================================================================
-	@Test void a28_compare() {
-		assertTrue(compare("a","b") < 0);
-		assertTrue(compare("b","a") > 0);
-		assertTrue(compare(null,"b") < 0);
-		assertTrue(compare("b",null) > 0);
-		assertEquals(0, compare(null,null));
+	@Test
+	void a28_compare() {
+		assertTrue(compare("a", "b") < 0);
+		assertTrue(compare("b", "a") > 0);
+		assertTrue(compare(null, "b") < 0);
+		assertTrue(compare("b", null) > 0);
+		assertEquals(0, compare(null, null));
 	}
 
 	//====================================================================================================
 	// matchPattern(String)
 	//====================================================================================================
-	@Test void a29_getMatchPattern() {
+	@Test
+	void a29_getMatchPattern() {
 		assertTrue(StringUtils.getMatchPattern("a").matcher("a").matches());
 		assertTrue(StringUtils.getMatchPattern("*a*").matcher("aaa").matches());
 		assertFalse(StringUtils.getMatchPattern("*b*").matcher("aaa").matches());
@@ -847,7 +840,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// getDuration(String)
 	//====================================================================================================
-	@Test void a30_getDuration() {
+	@Test
+	void a30_getDuration() {
 		assertEquals(-1, getDuration(null));
 		assertEquals(-1, getDuration(""));
 		assertEquals(-1, getDuration(" "));
@@ -855,83 +849,79 @@ class StringUtils_Test extends TestBase {
 		assertEquals(10, getDuration("10"));
 		assertEquals(10, getDuration("10"));
 
-		long
-			s = 1000,
-			m = s * 60,
-			h = m * 60,
-			d = h * 24,
-			w = d * 7;
-
-		assertEquals(10*s, getDuration("10s"));
-		assertEquals(10*s, getDuration("10 s"));
-		assertEquals(10*s, getDuration("  10  s  "));
-		assertEquals(10*s, getDuration("10sec"));
-		assertEquals(10*s, getDuration("10 sec"));
-		assertEquals(10*s, getDuration("  10  sec  "));
-		assertEquals(10*s, getDuration("10seconds"));
-		assertEquals(10*s, getDuration("10 seconds"));
-		assertEquals(10*s, getDuration("  10  seconds  "));
-		assertEquals(10*s, getDuration("10S"));
-		assertEquals(10*s, getDuration("10 S"));
-		assertEquals(10*s, getDuration("  10  S  "));
-
-		assertEquals(10*m, getDuration("10m"));
-		assertEquals(10*m, getDuration("10 m"));
-		assertEquals(10*m, getDuration("  10  m  "));
-		assertEquals(10*m, getDuration("10min"));
-		assertEquals(10*m, getDuration("10 min"));
-		assertEquals(10*m, getDuration("  10  min  "));
-		assertEquals(10*m, getDuration("10minutes"));
-		assertEquals(10*m, getDuration("10 minutes"));
-		assertEquals(10*m, getDuration("  10  minutes  "));
-		assertEquals(10*m, getDuration("10M"));
-		assertEquals(10*m, getDuration("10 M"));
-		assertEquals(10*m, getDuration("  10  M  "));
-
-		assertEquals(10*h, getDuration("10h"));
-		assertEquals(10*h, getDuration("10 h"));
-		assertEquals(10*h, getDuration("  10  h  "));
-		assertEquals(10*h, getDuration("10hour"));
-		assertEquals(10*h, getDuration("10 hour"));
-		assertEquals(10*h, getDuration("  10  hour  "));
-		assertEquals(10*h, getDuration("10hours"));
-		assertEquals(10*h, getDuration("10 hours"));
-		assertEquals(10*h, getDuration("  10  hours  "));
-		assertEquals(10*h, getDuration("10H"));
-		assertEquals(10*h, getDuration("10 H"));
-		assertEquals(10*h, getDuration("  10  H  "));
-
-		assertEquals(10*d, getDuration("10d"));
-		assertEquals(10*d, getDuration("10 d"));
-		assertEquals(10*d, getDuration("  10  d  "));
-		assertEquals(10*d, getDuration("10day"));
-		assertEquals(10*d, getDuration("10 day"));
-		assertEquals(10*d, getDuration("  10  day  "));
-		assertEquals(10*d, getDuration("10days"));
-		assertEquals(10*d, getDuration("10 days"));
-		assertEquals(10*d, getDuration("  10  days  "));
-		assertEquals(10*d, getDuration("10D"));
-		assertEquals(10*d, getDuration("10 D"));
-		assertEquals(10*d, getDuration("  10  D  "));
-
-		assertEquals(10*w, getDuration("10w"));
-		assertEquals(10*w, getDuration("10 w"));
-		assertEquals(10*w, getDuration("  10  w  "));
-		assertEquals(10*w, getDuration("10week"));
-		assertEquals(10*w, getDuration("10 week"));
-		assertEquals(10*w, getDuration("  10  week  "));
-		assertEquals(10*w, getDuration("10weeks"));
-		assertEquals(10*w, getDuration("10 weeks"));
-		assertEquals(10*w, getDuration("  10  weeks  "));
-		assertEquals(10*w, getDuration("10W"));
-		assertEquals(10*w, getDuration("10 W"));
-		assertEquals(10*w, getDuration("  10  W  "));
+		long s = 1000, m = s * 60, h = m * 60, d = h * 24, w = d * 7;
+
+		assertEquals(10 * s, getDuration("10s"));
+		assertEquals(10 * s, getDuration("10 s"));
+		assertEquals(10 * s, getDuration("  10  s  "));
+		assertEquals(10 * s, getDuration("10sec"));
+		assertEquals(10 * s, getDuration("10 sec"));
+		assertEquals(10 * s, getDuration("  10  sec  "));
+		assertEquals(10 * s, getDuration("10seconds"));
+		assertEquals(10 * s, getDuration("10 seconds"));
+		assertEquals(10 * s, getDuration("  10  seconds  "));
+		assertEquals(10 * s, getDuration("10S"));
+		assertEquals(10 * s, getDuration("10 S"));
+		assertEquals(10 * s, getDuration("  10  S  "));
+
+		assertEquals(10 * m, getDuration("10m"));
+		assertEquals(10 * m, getDuration("10 m"));
+		assertEquals(10 * m, getDuration("  10  m  "));
+		assertEquals(10 * m, getDuration("10min"));
+		assertEquals(10 * m, getDuration("10 min"));
+		assertEquals(10 * m, getDuration("  10  min  "));
+		assertEquals(10 * m, getDuration("10minutes"));
+		assertEquals(10 * m, getDuration("10 minutes"));
+		assertEquals(10 * m, getDuration("  10  minutes  "));
+		assertEquals(10 * m, getDuration("10M"));
+		assertEquals(10 * m, getDuration("10 M"));
+		assertEquals(10 * m, getDuration("  10  M  "));
+
+		assertEquals(10 * h, getDuration("10h"));
+		assertEquals(10 * h, getDuration("10 h"));
+		assertEquals(10 * h, getDuration("  10  h  "));
+		assertEquals(10 * h, getDuration("10hour"));
+		assertEquals(10 * h, getDuration("10 hour"));
+		assertEquals(10 * h, getDuration("  10  hour  "));
+		assertEquals(10 * h, getDuration("10hours"));
+		assertEquals(10 * h, getDuration("10 hours"));
+		assertEquals(10 * h, getDuration("  10  hours  "));
+		assertEquals(10 * h, getDuration("10H"));
+		assertEquals(10 * h, getDuration("10 H"));
+		assertEquals(10 * h, getDuration("  10  H  "));
+
+		assertEquals(10 * d, getDuration("10d"));
+		assertEquals(10 * d, getDuration("10 d"));
+		assertEquals(10 * d, getDuration("  10  d  "));
+		assertEquals(10 * d, getDuration("10day"));
+		assertEquals(10 * d, getDuration("10 day"));
+		assertEquals(10 * d, getDuration("  10  day  "));
+		assertEquals(10 * d, getDuration("10days"));
+		assertEquals(10 * d, getDuration("10 days"));
+		assertEquals(10 * d, getDuration("  10  days  "));
+		assertEquals(10 * d, getDuration("10D"));
+		assertEquals(10 * d, getDuration("10 D"));
+		assertEquals(10 * d, getDuration("  10  D  "));
+
+		assertEquals(10 * w, getDuration("10w"));
+		assertEquals(10 * w, getDuration("10 w"));
+		assertEquals(10 * w, getDuration("  10  w  "));
+		assertEquals(10 * w, getDuration("10week"));
+		assertEquals(10 * w, getDuration("10 week"));
+		assertEquals(10 * w, getDuration("  10  week  "));
+		assertEquals(10 * w, getDuration("10weeks"));
+		assertEquals(10 * w, getDuration("10 weeks"));
+		assertEquals(10 * w, getDuration("  10  weeks  "));
+		assertEquals(10 * w, getDuration("10W"));
+		assertEquals(10 * w, getDuration("10 W"));
+		assertEquals(10 * w, getDuration("  10  W  "));
 	}
 
 	//====================================================================================================
 	// getDuration(String)
 	//====================================================================================================
-	@Test void a31_stripInvalidHttpHeaderChars() {
+	@Test
+	void a31_stripInvalidHttpHeaderChars() {
 		assertEquals("xxx", stripInvalidHttpHeaderChars("xxx"));
 		assertEquals("\t []^x", stripInvalidHttpHeaderChars("\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u0020\\[]^x"));
 	}
@@ -939,7 +929,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// abbreviate(String,int)
 	//====================================================================================================
-	@Test void a32_abbrevate() {
+	@Test
+	void a32_abbrevate() {
 		assertNull(abbreviate(null, 0));
 		assertEquals("foo", abbreviate("foo", 3));
 		assertEquals("...", abbreviate("fooo", 3));
@@ -950,10 +941,11 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// splitMethodArgs(String)
 	//====================================================================================================
-	@Test void a33_splitMethodArgs() {
+	@Test
+	void a33_splitMethodArgs() {
 		assertList(StringUtils.splitMethodArgs("java.lang.String"), "java.lang.String");
 		assertList(StringUtils.splitMethodArgs("java.lang.String,java.lang.Integer"), "java.lang.String", "java.lang.Integer");
-		assertList(StringUtils.splitMethodArgs("x,y"), "x","y");
+		assertList(StringUtils.splitMethodArgs("x,y"), "x", "y");
 		assertList(StringUtils.splitMethodArgs("x,y<a,b>,z"), "x", "y<a,b>", "z");
 		assertList(StringUtils.splitMethodArgs("x,y<a<b,c>,d<e,f>>,z"), "x", "y<a<b,c>,d<e,f>>", "z");
 	}
@@ -961,7 +953,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// fixUrl(String)
 	//====================================================================================================
-	@Test void a34_fixUrl() {
+	@Test
+	void a34_fixUrl() {
 		assertEquals(null, fixUrl(null));
 		assertEquals("", fixUrl(""));
 		assertEquals("xxx", fixUrl("xxx"));
@@ -974,7 +967,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// diffPosition(String,String)
 	//====================================================================================================
-	@Test void a35_diffPosition() {
+	@Test
+	void a35_diffPosition() {
 		assertEquals(-1, diffPosition("a", "a"));
 		assertEquals(-1, diffPosition(null, null));
 		assertEquals(0, diffPosition("a", "b"));
@@ -988,7 +982,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// diffPositionIc(String,String)
 	//====================================================================================================
-	@Test void a36_diffPositionIc() {
+	@Test
+	void a36_diffPositionIc() {
 		assertEquals(-1, diffPositionIc("a", "a"));
 		assertEquals(-1, diffPositionIc("a", "A"));
 		assertEquals(-1, diffPositionIc(null, null));
@@ -1003,7 +998,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// splitNested(String)
 	//====================================================================================================
-	@Test void a37_splitNested() {
+	@Test
+	void a37_splitNested() {
 		assertNull(StringUtils.splitNested(null));
 		assertEmpty(StringUtils.splitNested(""));
 		assertList(StringUtils.splitNested("a"), "a");
@@ -1019,9 +1015,10 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// splitNestedInner(String)
 	//====================================================================================================
-	@Test void a38_splitNestedInner() {
-		assertThrowsWithMessage(IllegalArgumentException.class, "String was null.", ()->StringUtils.splitNestedInner(null));
-		assertThrowsWithMessage(IllegalArgumentException.class, "String was empty.", ()->StringUtils.splitNestedInner(""));
+	@Test
+	void a38_splitNestedInner() {
+		assertThrowsWithMessage(IllegalArgumentException.class, "String was null.", () -> StringUtils.splitNestedInner(null));
+		assertThrowsWithMessage(IllegalArgumentException.class, "String was empty.", () -> StringUtils.splitNestedInner(""));
 		assertList(StringUtils.splitNestedInner("a{b}"), "b");
 		assertList(StringUtils.splitNestedInner(" a { b } "), "b");
 		assertList(StringUtils.splitNestedInner("a{b,c}"), "b", "c");
@@ -1031,7 +1028,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toHex2(int)
 	//====================================================================================================
-	@Test void a38_toHex2() {
+	@Test
+	void a38_toHex2() {
 		// Test zero
 		assertString("00", toHex2(0));
 
@@ -1045,14 +1043,14 @@ class StringUtils_Test extends TestBase {
 		assertString("FF", toHex2(255));
 
 		// Test values outside valid range - should throw exception
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(256));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(511));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(Integer.MAX_VALUE));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(256));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(511));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(Integer.MAX_VALUE));
 
 		// Test negative numbers - should throw exception
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(-1));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(-100));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", ()->toHex2(Integer.MIN_VALUE));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(-1));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(-100));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex2 can only be used on numbers between 0 and 255", () -> toHex2(Integer.MIN_VALUE));
 
 		// Test edge cases
 		assertString("0A", toHex2(10));
@@ -1065,7 +1063,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toHex4(int)
 	//====================================================================================================
-	@Test void a39_toHex4() {
+	@Test
+	void a39_toHex4() {
 		// Test zero
 		assertString("0000", toHex4(0));
 
@@ -1091,9 +1090,9 @@ class StringUtils_Test extends TestBase {
 		assertString("FFFF", toHex4(Integer.MAX_VALUE));
 
 		// Test negative numbers - should throw exception
-		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", ()->toHex4(-1));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", ()->toHex4(-100));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", ()->toHex4(Integer.MIN_VALUE));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", () -> toHex4(-1));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", () -> toHex4(-100));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex4 can only be used on non-negative numbers", () -> toHex4(Integer.MIN_VALUE));
 
 		// Test edge cases
 		assertString("000A", toHex4(10));
@@ -1106,7 +1105,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toHex8(long)
 	//====================================================================================================
-	@Test void a40_toHex8() {
+	@Test
+	void a40_toHex8() {
 		// Test zero
 		assertString("00000000", toHex8(0));
 
@@ -1135,9 +1135,9 @@ class StringUtils_Test extends TestBase {
 		assertString("FFFFFFFF", toHex8(Long.MAX_VALUE));
 
 		// Test negative numbers - should throw exception
-		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", ()->toHex8(-1));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", ()->toHex8(-100));
-		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", ()->toHex8(Long.MIN_VALUE));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", () -> toHex8(-1));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", () -> toHex8(-100));
+		assertThrowsWithMessage(NumberFormatException.class, "toHex8 can only be used on non-negative numbers", () -> toHex8(Long.MIN_VALUE));
 
 		// Test edge cases
 		assertString("0000000A", toHex8(10));
@@ -1151,7 +1151,8 @@ class StringUtils_Test extends TestBase {
 	// String validation methods
 	//====================================================================================================
 
-	@Test void a41_isBlank() {
+	@Test
+	void a41_isBlank() {
 		assertTrue(StringUtils.isBlank(null));
 		assertTrue(StringUtils.isBlank(""));
 		assertTrue(StringUtils.isBlank("   "));
@@ -1161,7 +1162,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(StringUtils.isBlank("a"));
 	}
 
-	@Test void a42_isNotBlank() {
+	@Test
+	void a42_isNotBlank() {
 		assertFalse(isNotBlank(null));
 		assertFalse(isNotBlank(""));
 		assertFalse(isNotBlank("   "));
@@ -1171,7 +1173,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isNotBlank("a"));
 	}
 
-	@Test void a43_isEmpty() {
+	@Test
+	void a43_isEmpty() {
 		assertTrue(Utils.isEmpty((String)null));
 		assertTrue(Utils.isEmpty(""));
 		assertFalse(Utils.isEmpty("   "));
@@ -1179,7 +1182,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(Utils.isEmpty("a"));
 	}
 
-	@Test void a44_hasText() {
+	@Test
+	void a44_hasText() {
 		assertFalse(hasText(null));
 		assertFalse(hasText(""));
 		assertFalse(hasText("   "));
@@ -1189,7 +1193,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(hasText("a"));
 	}
 
-	@Test void a45_isAlpha() {
+	@Test
+	void a45_isAlpha() {
 		assertFalse(isAlpha(null));
 		assertFalse(isAlpha(""));
 		assertTrue(isAlpha("abc"));
@@ -1201,7 +1206,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isAlpha("123"));
 	}
 
-	@Test void a46_isAlphaNumeric() {
+	@Test
+	void a46_isAlphaNumeric() {
 		assertFalse(isAlphaNumeric(null));
 		assertFalse(isAlphaNumeric(""));
 		assertTrue(isAlphaNumeric("abc"));
@@ -1213,7 +1219,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isAlphaNumeric("abc_123"));
 	}
 
-	@Test void a47_isDigit() {
+	@Test
+	void a47_isDigit() {
 		assertFalse(isDigit(null));
 		assertFalse(isDigit(""));
 		assertTrue(isDigit("123"));
@@ -1225,7 +1232,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isDigit("12-3"));
 	}
 
-	@Test void a48_isWhitespace() {
+	@Test
+	void a48_isWhitespace() {
 		assertFalse(isWhitespace(null));
 		assertTrue(isWhitespace(""));
 		assertTrue(isWhitespace("   "));
@@ -1235,7 +1243,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isWhitespace("hello"));
 	}
 
-	@Test void a49_isEmpty() {
+	@Test
+	void a49_isEmpty() {
 		assertTrue(StringUtils.isEmpty(null));
 		assertTrue(StringUtils.isEmpty(""));
 		assertFalse(StringUtils.isEmpty(" "));
@@ -1243,7 +1252,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(StringUtils.isEmpty("hello"));
 	}
 
-	@Test void a50_isEmail() {
+	@Test
+	void a50_isEmail() {
 		assertFalse(isEmail(null));
 		assertFalse(isEmail(""));
 		assertFalse(isEmail(" "));
@@ -1267,7 +1277,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isEmail("user.name+tag+sorting@example.com"));
 	}
 
-	@Test void a51_isPhoneNumber() {
+	@Test
+	void a51_isPhoneNumber() {
 		assertFalse(isPhoneNumber(null));
 		assertFalse(isPhoneNumber(""));
 		assertFalse(isPhoneNumber(" "));
@@ -1289,7 +1300,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isPhoneNumber("+1 (123) 456-7890"));
 	}
 
-	@Test void a52_isCreditCard() {
+	@Test
+	void a52_isCreditCard() {
 		assertFalse(isCreditCard(null));
 		assertFalse(isCreditCard(""));
 		assertFalse(isCreditCard(" "));
@@ -1310,7 +1322,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isCreditCard("5555555555554444")); // MasterCard test card
 	}
 
-	@Test void a53_indexOf() {
+	@Test
+	void a53_indexOf() {
 		assertEquals(6, StringUtils.indexOf("hello world", "world"));
 		assertEquals(0, StringUtils.indexOf("hello world", "hello"));
 		assertEquals(2, StringUtils.indexOf("hello world", "llo"));
@@ -1322,7 +1335,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(-1, StringUtils.indexOf("hello", "hello world"));
 	}
 
-	@Test void a54_indexOfIgnoreCase() {
+	@Test
+	void a54_indexOfIgnoreCase() {
 		assertEquals(6, indexOfIgnoreCase("Hello World", "world"));
 		assertEquals(6, indexOfIgnoreCase("Hello World", "WORLD"));
 		assertEquals(0, indexOfIgnoreCase("Hello World", "hello"));
@@ -1334,7 +1348,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(-1, indexOfIgnoreCase(null, null));
 	}
 
-	@Test void a55_lastIndexOf() {
+	@Test
+	void a55_lastIndexOf() {
 		assertEquals(12, lastIndexOf("hello world world", "world"));
 		assertEquals(6, lastIndexOf("hello world", "world"));
 		assertEquals(0, lastIndexOf("hello world", "hello"));
@@ -1345,7 +1360,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(4, lastIndexOf("ababab", "ab")); // "ab" appears at positions 0, 2, 4
 	}
 
-	@Test void a56_lastIndexOfIgnoreCase() {
+	@Test
+	void a56_lastIndexOfIgnoreCase() {
 		assertEquals(12, lastIndexOfIgnoreCase("Hello World World", "world"));
 		assertEquals(12, lastIndexOfIgnoreCase("Hello World World", "WORLD"));
 		assertEquals(6, lastIndexOfIgnoreCase("Hello World", "world"));
@@ -1358,7 +1374,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(4, lastIndexOfIgnoreCase("AbAbAb", "ab"));
 	}
 
-	@Test void a57_containsIgnoreCase() {
+	@Test
+	void a57_containsIgnoreCase() {
 		assertTrue(containsIgnoreCase("Hello World", "world"));
 		assertTrue(containsIgnoreCase("Hello World", "WORLD"));
 		assertTrue(containsIgnoreCase("Hello World", "hello"));
@@ -1371,7 +1388,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(containsIgnoreCase("Hello", "hello"));
 	}
 
-	@Test void a58_startsWithIgnoreCase() {
+	@Test
+	void a58_startsWithIgnoreCase() {
 		assertTrue(startsWithIgnoreCase("Hello World", "hello"));
 		assertTrue(startsWithIgnoreCase("Hello World", "HELLO"));
 		assertTrue(startsWithIgnoreCase("Hello World", "Hello"));
@@ -1384,7 +1402,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(startsWithIgnoreCase("Hello", "hello"));
 	}
 
-	@Test void a59_endsWithIgnoreCase() {
+	@Test
+	void a59_endsWithIgnoreCase() {
 		assertTrue(endsWithIgnoreCase("Hello World", "world"));
 		assertTrue(endsWithIgnoreCase("Hello World", "WORLD"));
 		assertTrue(endsWithIgnoreCase("Hello World", "World"));
@@ -1397,7 +1416,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(endsWithIgnoreCase("Hello", "hello"));
 	}
 
-	@Test void a60_matches() {
+	@Test
+	void a60_matches() {
 		assertTrue(matches("12345", "\\d+"));
 		assertTrue(matches("abc123", "^[a-z]+\\d+$"));
 		assertTrue(matches("hello", "^hello$"));
@@ -1411,7 +1431,8 @@ class StringUtils_Test extends TestBase {
 		assertThrows(java.util.regex.PatternSyntaxException.class, () -> matches("test", "["));
 	}
 
-	@Test void a61_countMatches() {
+	@Test
+	void a61_countMatches() {
 		assertEquals(2, countMatches("hello world world", "world"));
 		assertEquals(3, countMatches("ababab", "ab"));
 		assertEquals(4, countMatches("aaaa", "a"));
@@ -1428,8 +1449,9 @@ class StringUtils_Test extends TestBase {
 		assertEquals(2, countMatches("aaaa", "aa")); // "aa" appears at positions 0 and 2
 	}
 
-	@Test void a62_formatWithNamedArgs() {
-		var args = new HashMap<String, Object>();
+	@Test
+	void a62_formatWithNamedArgs() {
+		var args = new HashMap<String,Object>();
 		args.put("name", "John");
 		args.put("age", 30);
 		args.put("city", "New York");
@@ -1441,14 +1463,73 @@ class StringUtils_Test extends TestBase {
 		assertEquals("Template", formatWithNamedArgs("Template", null));
 		assertEquals("Template", formatWithNamedArgs("Template", new HashMap<>()));
 		// Test with null values
-		var argsWithNull = new HashMap<String, Object>();
+		var argsWithNull = new HashMap<String,Object>();
 		argsWithNull.put("name", "John");
 		argsWithNull.put("value", null);
 		assertEquals("Hello John, value: ", formatWithNamedArgs("Hello {name}, value: {value}", argsWithNull));
 	}
 
-	@Test void a63_interpolate() {
-		var vars = new HashMap<String, Object>();
+	//====================================================================================================
+	// format(String, Object...)
+	//====================================================================================================
+	@Test
+	void a62b_format() {
+		// Basic string and number formatting
+		assertEquals("Hello John, you have 5 items", format("Hello %s, you have %d items", "John", 5));
+		assertEquals("Hello world", format("Hello %s", "world"));
+
+		// Floating point with precision
+		assertEquals("Price: $19.99", format("Price: $%.2f", 19.99));
+		assertEquals("Value: 3.14", format("Value: %.2f", 3.14159));
+		assertEquals("Value: 3.142", format("Value: %.3f", 3.14159));
+
+		// Width and alignment
+		assertEquals("Name: John                 Age:  25", format("Name: %-20s Age: %3d", "John", 25));
+		assertEquals("Name:                 John Age:  25", format("Name: %20s Age: %3d", "John", 25));
+		assertEquals("Number:   42", format("Number: %4d", 42));
+		assertEquals("Number: 0042", format("Number: %04d", 42));
+
+		// Hexadecimal
+		assertEquals("Color: #FF5733", format("Color: #%06X", 0xFF5733));
+		assertEquals("Hex: ff5733", format("Hex: %x", 0xFF5733));
+		assertEquals("Hex: FF5733", format("Hex: %X", 0xFF5733));
+		assertEquals("Hex: 255", format("Hex: %d", 0xFF));
+
+		// Octal
+		assertEquals("Octal: 377", format("Octal: %o", 255));
+
+		// Scientific notation
+		assertEquals("Value: 1.23e+06", format("Value: %.2e", 1234567.0));
+		assertEquals("Value: 1.23E+06", format("Value: %.2E", 1234567.0));
+
+		// Boolean
+		assertEquals("Flag: true", format("Flag: %b", true));
+		assertEquals("Flag: false", format("Flag: %b", false));
+		assertEquals("Flag: true", format("Flag: %b", "anything"));
+
+		// Character
+		assertEquals("Char: A", format("Char: %c", 'A'));
+		assertEquals("Char: A", format("Char: %c", 65));
+
+		// Argument index (reuse arguments)
+		assertEquals("Alice loves Bob, and Alice also loves Charlie", format("%1$s loves %2$s, and %1$s also loves %3$s", "Alice", "Bob", "Charlie"));
+
+		// Literal percent sign
+		assertEquals("Progress: 50%", format("Progress: %d%%", 50));
+		assertEquals("Discount: 25% off", format("Discount: %d%% off", 25));
+
+		// Line separator
+		var result = format("Line 1%nLine 2");
+		assertTrue(result.contains("Line 1"));
+		assertTrue(result.contains("Line 2"));
+
+		// Multiple format specifiers
+		assertEquals("Name: John, Age: 30, Salary: $50000.00", format("Name: %s, Age: %d, Salary: $%.2f", "John", 30, 50000.0));
+	}
+
+	@Test
+	void a63_interpolate() {
+		var vars = new HashMap<String,Object>();
 		vars.put("name", "John");
 		vars.put("city", "New York");
 		vars.put("age", 30);
@@ -1460,18 +1541,18 @@ class StringUtils_Test extends TestBase {
 		assertEquals("Template", interpolate("Template", null));
 		assertEquals("Template", interpolate("Template", new HashMap<>()));
 		// Test with null values
-		var varsWithNull = new HashMap<String, Object>();
+		var varsWithNull = new HashMap<String,Object>();
 		varsWithNull.put("name", "John");
 		varsWithNull.put("value", null);
 		assertEquals("Hello John, value: null", interpolate("Hello ${name}, value: ${value}", varsWithNull));
 		// Test with incomplete placeholder
 		assertEquals("Hello ${name", interpolate("Hello ${name", vars));
 		// Test with multiple variables
-		assertEquals("John is 30 years old and lives in New York",
-			interpolate("${name} is ${age} years old and lives in ${city}", vars));
+		assertEquals("John is 30 years old and lives in New York", interpolate("${name} is ${age} years old and lives in ${city}", vars));
 	}
 
-	@Test void a64_pluralize() {
+	@Test
+	void a64_pluralize() {
 		// Singular (count = 1)
 		assertEquals("cat", pluralize("cat", 1));
 		assertEquals("box", pluralize("box", 1));
@@ -1509,7 +1590,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("as", pluralize("a", 2));
 	}
 
-	@Test void a65_ordinal() {
+	@Test
+	void a65_ordinal() {
 		// Basic ordinals
 		assertEquals("1st", ordinal(1));
 		assertEquals("2nd", ordinal(2));
@@ -1552,7 +1634,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("0th", ordinal(0));
 	}
 
-	@Test void a66_sanitize() {
+	@Test
+	void a66_sanitize() {
 		assertNull(sanitize(null));
 		assertEquals("", sanitize(""));
 		assertEquals("Hello World", sanitize("Hello World"));
@@ -1561,7 +1644,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;", sanitize("<img src=\"x\" onerror=\"alert(1)\">"));
 	}
 
-	@Test void a67_escapeHtml() {
+	@Test
+	void a67_escapeHtml() {
 		assertNull(escapeHtml(null));
 		assertEquals("", escapeHtml(""));
 		assertEquals("Hello World", escapeHtml("Hello World"));
@@ -1578,7 +1662,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("&#39;", escapeHtml("'"));
 	}
 
-	@Test void a68_unescapeHtml() {
+	@Test
+	void a68_unescapeHtml() {
 		assertNull(unescapeHtml(null));
 		assertEquals("", unescapeHtml(""));
 		assertEquals("Hello World", unescapeHtml("Hello World"));
@@ -1593,7 +1678,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("<script>alert('xss')</script>", unescapeHtml(escapeHtml("<script>alert('xss')</script>")));
 	}
 
-	@Test void a69_escapeXml() {
+	@Test
+	void a69_escapeXml() {
 		assertNull(escapeXml(null));
 		assertEquals("", escapeXml(""));
 		assertEquals("Hello World", escapeXml("Hello World"));
@@ -1610,7 +1696,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("&apos;", escapeXml("'"));
 	}
 
-	@Test void a70_unescapeXml() {
+	@Test
+	void a70_unescapeXml() {
 		assertNull(unescapeXml(null));
 		assertEquals("", unescapeXml(""));
 		assertEquals("Hello World", unescapeXml("Hello World"));
@@ -1624,7 +1711,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("<tag>text</tag>", unescapeXml(escapeXml("<tag>text</tag>")));
 	}
 
-	@Test void a71_escapeSql() {
+	@Test
+	void a71_escapeSql() {
 		assertNull(escapeSql(null));
 		assertEquals("", escapeSql(""));
 		assertEquals("Hello World", escapeSql("Hello World"));
@@ -1635,7 +1723,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("John''s book", escapeSql("John's book"));
 	}
 
-	@Test void a72_escapeRegex() {
+	@Test
+	void a72_escapeRegex() {
 		assertNull(escapeRegex(null));
 		assertEquals("", escapeRegex(""));
 		assertEquals("Hello World", escapeRegex("Hello World"));
@@ -1657,7 +1746,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(pattern.matcher("filextxt").matches());
 	}
 
-	@Test void a73_equalsIgnoreCase() {
+	@Test
+	void a73_equalsIgnoreCase() {
 		assertTrue(equalsIgnoreCase("Hello", "hello"));
 		assertTrue(equalsIgnoreCase("HELLO", "hello"));
 		assertTrue(equalsIgnoreCase("Hello", "HELLO"));
@@ -1669,7 +1759,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(equalsIgnoreCase("", ""));
 	}
 
-	@Test void a74_compareIgnoreCase() {
+	@Test
+	void a74_compareIgnoreCase() {
 		assertTrue(compareIgnoreCase("apple", "BANANA") < 0);
 		assertTrue(compareIgnoreCase("BANANA", "apple") > 0);
 		assertEquals(0, compareIgnoreCase("Hello", "hello"));
@@ -1681,7 +1772,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(compareIgnoreCase("test", null) > 0);
 	}
 
-	@Test void a75_naturalCompare() {
+	@Test
+	void a75_naturalCompare() {
 		// Numbers should be compared numerically
 		assertTrue(naturalCompare("file2.txt", "file10.txt") < 0);
 		assertTrue(naturalCompare("file10.txt", "file2.txt") > 0);
@@ -1710,7 +1802,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(naturalCompare("banana", "Apple") > 0);
 	}
 
-	@Test void a76_levenshteinDistance() {
+	@Test
+	void a76_levenshteinDistance() {
 		assertEquals(0, levenshteinDistance("hello", "hello"));
 		assertEquals(3, levenshteinDistance("kitten", "sitting")); // kitten -> sitten (s), sitten -> sittin (i), sittin -> sitting (g)
 		assertEquals(3, levenshteinDistance("abc", ""));
@@ -1725,11 +1818,12 @@ class StringUtils_Test extends TestBase {
 		assertEquals(5, levenshteinDistance(null, "hello"));
 	}
 
-	@Test void a77_similarity() {
+	@Test
+	void a77_similarity() {
 		assertEquals(1.0, similarity("hello", "hello"), 0.0001);
 		assertEquals(0.0, similarity("abc", "xyz"), 0.0001);
 		// kitten -> sitting: distance = 3, maxLen = 7, similarity = 1 - 3/7 = 4/7 ≈ 0.571
-		assertEquals(4.0/7.0, similarity("kitten", "sitting"), 0.01);
+		assertEquals(4.0 / 7.0, similarity("kitten", "sitting"), 0.01);
 		assertEquals(1.0, similarity("", ""), 0.0001);
 		assertEquals(0.0, similarity("abc", ""), 0.0001);
 		assertEquals(0.0, similarity("", "abc"), 0.0001);
@@ -1742,7 +1836,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(0.8, similarity("hello", "hallo"), 0.0001);
 	}
 
-	@Test void a78_isSimilar() {
+	@Test
+	void a78_isSimilar() {
 		assertTrue(isSimilar("hello", "hello", 0.8));
 		assertTrue(isSimilar("hello", "hello", 1.0));
 		assertFalse(isSimilar("kitten", "sitting", 0.8));
@@ -1754,7 +1849,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(isSimilar(null, null, 0.8));
 	}
 
-	@Test void a79_generateUUID() {
+	@Test
+	void a79_generateUUID() {
 		// Generate multiple UUIDs and verify format
 		for (var i = 0; i < 10; i++) {
 			var uuid = generateUUID();
@@ -1769,7 +1865,8 @@ class StringUtils_Test extends TestBase {
 		assertNotEquals(uuid1, uuid2);
 	}
 
-	@Test void a80_randomAlphabetic() {
+	@Test
+	void a80_randomAlphabetic() {
 		// Test various lengths
 		for (var len = 0; len <= 20; len++) {
 			var s = randomAlphabetic(len);
@@ -1791,7 +1888,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(strings.size() > 1, "Should generate different strings");
 	}
 
-	@Test void a81_randomAlphanumeric() {
+	@Test
+	void a81_randomAlphanumeric() {
 		// Test various lengths
 		for (var len = 0; len <= 20; len++) {
 			var s = randomAlphanumeric(len);
@@ -1817,15 +1915,19 @@ class StringUtils_Test extends TestBase {
 		for (var i = 0; i < 1000; i++) {
 			var s = randomAlphanumeric(20);
 			for (var j = 0; j < s.length(); j++) {
-				if (Character.isLetter(s.charAt(j))) hasLetter = true;
-				if (Character.isDigit(s.charAt(j))) hasDigit = true;
+				if (Character.isLetter(s.charAt(j)))
+					hasLetter = true;
+				if (Character.isDigit(s.charAt(j)))
+					hasDigit = true;
 			}
-			if (hasLetter && hasDigit) break;
+			if (hasLetter && hasDigit)
+				break;
 		}
 		assertTrue(hasLetter && hasDigit, "Should generate both letters and digits");
 	}
 
-	@Test void a82_randomNumeric() {
+	@Test
+	void a82_randomNumeric() {
 		// Test various lengths
 		for (var len = 0; len <= 20; len++) {
 			var s = randomNumeric(len);
@@ -1847,7 +1949,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(strings.size() > 1, "Should generate different strings");
 	}
 
-	@Test void a83_randomAscii() {
+	@Test
+	void a83_randomAscii() {
 		// Test various lengths
 		for (var len = 0; len <= 20; len++) {
 			var s = randomAscii(len);
@@ -1869,7 +1972,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(strings.size() > 1, "Should generate different strings");
 	}
 
-	@Test void a84_randomString() {
+	@Test
+	void a84_randomString() {
 		// Test with various character sets
 		var s1 = randomString(10, "ABC");
 		assertNotNull(s1);
@@ -1912,7 +2016,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(strings.size() > 1, "Should generate different strings");
 	}
 
-	@Test void a85_parseMap() {
+	@Test
+	void a85_parseMap() {
 		// Basic parsing
 		var map1 = parseMap("key1=value1,key2=value2", '=', ',', false);
 		assertEquals(2, map1.size());
@@ -1954,7 +2059,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("value2", map6.get("key"));
 	}
 
-	@Test void a86_extractNumbers() {
+	@Test
+	void a86_extractNumbers() {
 		// Basic extraction
 		var numbers1 = extractNumbers("Price: $19.99, Quantity: 5");
 		assertEquals(2, numbers1.size());
@@ -1988,7 +2094,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(extractNumbers("").isEmpty());
 	}
 
-	@Test void a87_extractEmails() {
+	@Test
+	void a87_extractEmails() {
 		// Basic extraction
 		var emails1 = extractEmails("Contact: user@example.com or admin@test.org");
 		assertEquals(2, emails1.size());
@@ -2014,7 +2121,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(extractEmails("").isEmpty());
 	}
 
-	@Test void a88_extractUrls() {
+	@Test
+	void a88_extractUrls() {
 		// Basic extraction
 		var urls1 = extractUrls("Visit https://example.com or http://test.org");
 		assertEquals(2, urls1.size());
@@ -2043,7 +2151,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(extractUrls("").isEmpty());
 	}
 
-	@Test void a89_extractWords() {
+	@Test
+	void a89_extractWords() {
 		// Basic extraction
 		var words1 = extractWords("Hello world! This is a test.");
 		assertEquals(6, words1.size());
@@ -2080,7 +2189,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(extractWords("").isEmpty());
 	}
 
-	@Test void a90_extractBetween() {
+	@Test
+	void a90_extractBetween() {
 		// Basic extraction
 		var results1 = extractBetween("<tag>content</tag>", "<", ">");
 		assertEquals(2, results1.size());
@@ -2120,7 +2230,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(extractBetween("text", "<", null).isEmpty());
 	}
 
-	@Test void a91_transliterate() {
+	@Test
+	void a91_transliterate() {
 		// Basic transliteration
 		assertEquals("h2ll4", transliterate("hello", "aeiou", "12345"));
 		assertEquals("XYZ", transliterate("ABC", "ABC", "XYZ"));
@@ -2147,7 +2258,8 @@ class StringUtils_Test extends TestBase {
 		assertThrows(IllegalArgumentException.class, () -> transliterate("hello", "abc", "12"));
 	}
 
-	@Test void a92_soundex() {
+	@Test
+	void a92_soundex() {
 		// Standard Soundex examples
 		assertEquals("S530", soundex("Smith"));
 		assertEquals("S530", soundex("Smythe"));
@@ -2173,7 +2285,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("A000", soundex("AEIOU"));
 	}
 
-	@Test void a93_metaphone() {
+	@Test
+	void a93_metaphone() {
 		// Basic metaphone examples
 		var code1 = metaphone("Smith");
 		assertNotNull(code1);
@@ -2198,7 +2311,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(code4.isEmpty());
 	}
 
-	@Test void a94_doubleMetaphone() {
+	@Test
+	void a94_doubleMetaphone() {
 		// Basic double metaphone
 		var codes1 = doubleMetaphone("Smith");
 		assertNotNull(codes1);
@@ -2215,7 +2329,8 @@ class StringUtils_Test extends TestBase {
 		assertNull(doubleMetaphone(""));
 	}
 
-	@Test void a95_normalizeUnicode() {
+	@Test
+	void a95_normalizeUnicode() {
 		// Basic normalization
 		var normalized = normalizeUnicode("café");
 		assertNotNull(normalized);
@@ -2229,7 +2344,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("hello", normalized2);
 	}
 
-	@Test void a96_removeAccents() {
+	@Test
+	void a96_removeAccents() {
 		// Basic accent removal
 		assertEquals("cafe", removeAccents("café"));
 		assertEquals("naive", removeAccents("naïve"));
@@ -2254,7 +2370,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("Ecole", removeAccents("École"));
 	}
 
-	@Test void a97_isValidRegex() {
+	@Test
+	void a97_isValidRegex() {
 		// Valid regex patterns
 		assertTrue(isValidRegex("[a-z]+"));
 		assertTrue(isValidRegex("\\d+"));
@@ -2272,7 +2389,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidRegex(""));
 	}
 
-	@Test void a98_isValidDateFormat() {
+	@Test
+	void a98_isValidDateFormat() {
 		// Valid dates
 		assertTrue(isValidDateFormat("2023-12-25", "yyyy-MM-dd"));
 		assertTrue(isValidDateFormat("25/12/2023", "dd/MM/yyyy"));
@@ -2292,7 +2410,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidDateFormat("2023-12-25", ""));
 	}
 
-	@Test void a99_isValidTimeFormat() {
+	@Test
+	void a99_isValidTimeFormat() {
 		// Valid times
 		assertTrue(isValidTimeFormat("14:30:00", "HH:mm:ss"));
 		assertTrue(isValidTimeFormat("02:30:00 PM", "hh:mm:ss a"));
@@ -2312,7 +2431,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidTimeFormat("14:30:00", ""));
 	}
 
-	@Test void a100_isValidIpAddress() {
+	@Test
+	void a100_isValidIpAddress() {
 		// Valid IPv4 addresses
 		assertTrue(isValidIpAddress("192.168.1.1"));
 		assertTrue(isValidIpAddress("0.0.0.0"));
@@ -2340,7 +2460,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidIpAddress(""));
 	}
 
-	@Test void a101_isValidMacAddress() {
+	@Test
+	void a101_isValidMacAddress() {
 		// Valid MAC addresses - colon format
 		assertTrue(isValidMacAddress("00:1B:44:11:3A:B7"));
 		assertTrue(isValidMacAddress("00:1b:44:11:3a:b7")); // Lowercase
@@ -2365,7 +2486,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidMacAddress(""));
 	}
 
-	@Test void a102_isValidHostname() {
+	@Test
+	void a102_isValidHostname() {
 		// Valid hostnames
 		assertTrue(isValidHostname("example.com"));
 		assertTrue(isValidHostname("sub.example.com"));
@@ -2396,7 +2518,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(isValidHostname(""));
 	}
 
-	@Test void a103_wordCount() {
+	@Test
+	void a103_wordCount() {
 		// Basic word counting
 		assertEquals(2, wordCount("Hello world"));
 		assertEquals(4, wordCount("The quick brown fox"));
@@ -2424,7 +2547,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(0, wordCount("!@#$%^&*()"));
 	}
 
-	@Test void a104_lineCount() {
+	@Test
+	void a104_lineCount() {
 		// Basic line counting
 		assertEquals(3, lineCount("line1\nline2\nline3"));
 		assertEquals(1, lineCount("single line"));
@@ -2448,7 +2572,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(0, lineCount(""));
 	}
 
-	@Test void a105_mostFrequentChar() {
+	@Test
+	void a105_mostFrequentChar() {
 		// Basic frequency
 		assertEquals('l', mostFrequentChar("hello"));
 		assertEquals('a', mostFrequentChar("aabbcc")); // First encountered
@@ -2473,7 +2598,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals('\0', mostFrequentChar(""));
 	}
 
-	@Test void a106_entropy() {
+	@Test
+	void a106_entropy() {
 		// No randomness (all same character)
 		assertEquals(0.0, entropy("aaaa"), 0.0001);
 
@@ -2501,7 +2627,8 @@ class StringUtils_Test extends TestBase {
 		assertTrue(entropy4 > entropy("hello"));
 	}
 
-	@Test void a107_readabilityScore() {
+	@Test
+	void a107_readabilityScore() {
 		// Simple sentence (should have higher score)
 		var score1 = readabilityScore("The cat sat.");
 		assertTrue(score1 > 0.0 && score1 <= 100.0);
@@ -2539,7 +2666,8 @@ class StringUtils_Test extends TestBase {
 	// String manipulation methods
 	//====================================================================================================
 
-	@Test void a49_capitalize() {
+	@Test
+	void a49_capitalize() {
 		assertNull(capitalize(null));
 		assertEquals("", capitalize(""));
 		assertEquals("Hello", capitalize("hello"));
@@ -2549,7 +2677,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("123", capitalize("123"));
 	}
 
-	@Test void a50_uncapitalize() {
+	@Test
+	void a50_uncapitalize() {
 		assertNull(uncapitalize(null));
 		assertEquals("", uncapitalize(""));
 		assertEquals("hello", uncapitalize("hello"));
@@ -2559,7 +2688,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("123", uncapitalize("123"));
 	}
 
-	@Test void a51_reverse() {
+	@Test
+	void a51_reverse() {
 		assertNull(StringUtils.reverse(null));
 		assertEquals("", reverse(""));
 		assertEquals("olleh", reverse("hello"));
@@ -2567,7 +2697,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("cba", reverse("abc"));
 	}
 
-	@Test void a52_remove() {
+	@Test
+	void a52_remove() {
 		assertNull(remove(null, "x"));
 		assertEquals("hello", remove("hello", null));
 		assertEquals("hello", remove("hello", ""));
@@ -2576,7 +2707,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", remove("xxx", "x"));
 	}
 
-	@Test void a53_removeStart() {
+	@Test
+	void a53_removeStart() {
 		assertNull(removeStart(null, "x"));
 		assertEquals("hello", removeStart("hello", null));
 		assertEquals("hello", removeStart("hello", ""));
@@ -2585,7 +2717,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", removeStart("hello", "hello"));
 	}
 
-	@Test void a54_removeEnd() {
+	@Test
+	void a54_removeEnd() {
 		assertNull(removeEnd(null, "x"));
 		assertEquals("hello", removeEnd("hello", null));
 		assertEquals("hello", removeEnd("hello", ""));
@@ -2594,7 +2727,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", removeEnd("hello", "hello"));
 	}
 
-	@Test void a55_substringBefore() {
+	@Test
+	void a55_substringBefore() {
 		assertNull(substringBefore(null, "."));
 		assertEquals("hello.world", substringBefore("hello.world", null));
 		assertEquals("hello", substringBefore("hello.world", "."));
@@ -2602,7 +2736,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", substringBefore(".world", "."));
 	}
 
-	@Test void a56_substringAfter() {
+	@Test
+	void a56_substringAfter() {
 		assertNull(substringAfter(null, "."));
 		assertEquals("", substringAfter("hello.world", null));
 		assertEquals("world", substringAfter("hello.world", "."));
@@ -2610,7 +2745,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("world", substringAfter("hello.world", "."));
 	}
 
-	@Test void a57_substringBetween() {
+	@Test
+	void a57_substringBetween() {
 		assertNull(substringBetween(null, "<", ">"));
 		assertNull(substringBetween("<hello>", null, ">"));
 		assertNull(substringBetween("<hello>", "<", null));
@@ -2620,7 +2756,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", substringBetween("<>", "<", ">"));
 	}
 
-	@Test void a58_left() {
+	@Test
+	void a58_left() {
 		assertNull(left(null, 3));
 		assertEquals("", left("", 3));
 		assertEquals("hel", left("hello", 3));
@@ -2629,7 +2766,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", left("hello", -1));
 	}
 
-	@Test void a59_right() {
+	@Test
+	void a59_right() {
 		assertNull(right(null, 3));
 		assertEquals("", right("", 3));
 		assertEquals("llo", right("hello", 3));
@@ -2638,7 +2776,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", right("hello", -1));
 	}
 
-	@Test void a60_mid() {
+	@Test
+	void a60_mid() {
 		assertNull(mid(null, 1, 3));
 		assertEquals("", mid("", 1, 3));
 		assertEquals("ell", mid("hello", 1, 3));
@@ -2648,7 +2787,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("", mid("hello", 1, -1));
 	}
 
-	@Test void a61_padLeft() {
+	@Test
+	void a61_padLeft() {
 		assertEquals("     ", padLeft(null, 5, ' '));
 		assertEquals("     ", padLeft("", 5, ' '));
 		assertEquals("   hello", padLeft("hello", 8, ' '));
@@ -2656,7 +2796,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("00123", padLeft("123", 5, '0'));
 	}
 
-	@Test void a62_padRight() {
+	@Test
+	void a62_padRight() {
 		assertEquals("     ", padRight(null, 5, ' '));
 		assertEquals("     ", padRight("", 5, ' '));
 		assertEquals("hello   ", padRight("hello", 8, ' '));
@@ -2664,7 +2805,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("12300", padRight("123", 5, '0'));
 	}
 
-	@Test void a63_padCenter() {
+	@Test
+	void a63_padCenter() {
 		assertEquals("     ", padCenter(null, 5, ' '));
 		assertEquals("     ", padCenter("", 5, ' '));
 		assertEquals("  hi  ", padCenter("hi", 6, ' '));
@@ -2677,7 +2819,8 @@ class StringUtils_Test extends TestBase {
 	// String joining and splitting methods
 	//====================================================================================================
 
-	@Test void a64_joinObjectArray() {
+	@Test
+	void a64_joinObjectArray() {
 		assertNull(StringUtils.join((Object[])null, ","));
 		assertEquals("", StringUtils.join(a(), ","));
 		assertEquals("a,b,c", StringUtils.join(a("a", "b", "c"), ","));
@@ -2687,7 +2830,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("a;b;c", StringUtils.join(a("a", "b", "c"), ";"));
 	}
 
-	@Test void a65_joinIntArray() {
+	@Test
+	void a65_joinIntArray() {
 		assertEquals("", StringUtils.join((int[])null, ","));
 		assertEquals("", StringUtils.join(ints(), ","));
 		assertEquals("1,2,3", StringUtils.join(ints(1, 2, 3), ","));
@@ -2695,7 +2839,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("123", StringUtils.join(ints(1, 2, 3), ""));
 	}
 
-	@Test void a66_joinCollection() {
+	@Test
+	void a66_joinCollection() {
 		assertNull(StringUtils.join((Collection<?>)null, ","));
 		assertEquals("", StringUtils.join(Collections.emptyList(), ","));
 		assertEquals("a,b,c", StringUtils.join(l("a", "b", "c"), ","));
@@ -2703,17 +2848,20 @@ class StringUtils_Test extends TestBase {
 		assertEquals("a,null,c", StringUtils.join(l("a", null, "c"), ","));
 	}
 
-	@Test void a67_joinObjectArrayChar() {
+	@Test
+	void a67_joinObjectArrayChar() {
 		assertEquals("a,b,c", StringUtils.join(a("a", "b", "c"), ','));
 		assertEquals("1-2-3", StringUtils.join(a(1, 2, 3), '-'));
 	}
 
-	@Test void a68_joinIntArrayChar() {
+	@Test
+	void a68_joinIntArrayChar() {
 		assertEquals("1,2,3", StringUtils.join(ints(1, 2, 3), ','));
 		assertEquals("1-2-3", StringUtils.join(ints(1, 2, 3), '-'));
 	}
 
-	@Test void a69_joinCollectionChar() {
+	@Test
+	void a69_joinCollectionChar() {
 		assertEquals("a,b,c", StringUtils.join(l("a", "b", "c"), ','));
 		assertEquals("1-2-3", StringUtils.join(l(1, 2, 3), '-'));
 	}
@@ -2722,7 +2870,8 @@ class StringUtils_Test extends TestBase {
 	// String cleaning and sanitization methods
 	//====================================================================================================
 
-	@Test void a72_clean() {
+	@Test
+	void a72_clean() {
 		assertNull(clean(null));
 		assertEquals("", clean(""));
 		assertEquals("hello world", clean("hello\u0000\u0001world"));
@@ -2730,7 +2879,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("test", clean("test"));
 	}
 
-	@Test void a73_normalizeWhitespace() {
+	@Test
+	void a73_normalizeWhitespace() {
 		assertNull(normalizeWhitespace(null));
 		assertEquals("", normalizeWhitespace(""));
 		assertEquals("hello world", normalizeWhitespace("hello  \t\n  world"));
@@ -2738,7 +2888,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals("a b c", normalizeWhitespace("a  b  c"));
 	}
 
-	@Test void a74_removeControlChars() {
+	@Test
+	void a74_removeControlChars() {
 		assertNull(removeControlChars(null));
 		assertEquals("", removeControlChars(""));
 		assertEquals("hello  world", removeControlChars("hello\u0000\u0001world"));
@@ -2746,14 +2897,16 @@ class StringUtils_Test extends TestBase {
 		assertEquals("test", removeControlChars("test"));
 	}
 
-	@Test void a75_removeNonPrintable() {
+	@Test
+	void a75_removeNonPrintable() {
 		assertNull(removeNonPrintable(null));
 		assertEquals("", removeNonPrintable(""));
 		assertEquals("helloworld", removeNonPrintable("hello\u0000world"));
 		assertEquals("test", removeNonPrintable("test"));
 	}
 
-	@Test void a76_swapCase() {
+	@Test
+	void a76_swapCase() {
 		assertNull(swapCase(null));
 		assertEquals("", swapCase(""));
 		assertEquals("hELLO wORLD", swapCase("Hello World"));
@@ -2764,14 +2917,16 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// lc / uc
 	//====================================================================================================
-	@Test void a77_lc() {
+	@Test
+	void a77_lc() {
 		assertNull(lc(null));
 		assertEquals("", lc(""));
 		assertEquals("hello", lc("HELLO"));
 		assertEquals("hello world", lc("Hello World"));
 	}
 
-	@Test void a78_uc() {
+	@Test
+	void a78_uc() {
 		assertNull(uc(null));
 		assertEquals("", uc(""));
 		assertEquals("HELLO", uc("hello"));
@@ -2781,7 +2936,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// eqic
 	//====================================================================================================
-	@Test void a79_eqic() {
+	@Test
+	void a79_eqic() {
 		assertTrue(eqic(null, null));
 		assertFalse(eqic("test", null));
 		assertFalse(eqic(null, "test"));
@@ -2796,7 +2952,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// articlized
 	//====================================================================================================
-	@Test void a80_articlized() {
+	@Test
+	void a80_articlized() {
 		assertEquals("an apple", articlized("apple"));
 		assertEquals("an Apple", articlized("Apple"));
 		assertEquals("a banana", articlized("banana"));
@@ -2810,7 +2967,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// obfuscate
 	//====================================================================================================
-	@Test void a81_obfuscate() {
+	@Test
+	void a81_obfuscate() {
 		assertEquals("*", obfuscate(null));
 		assertEquals("*", obfuscate(""));
 		assertEquals("*", obfuscate("a"));
@@ -2822,7 +2980,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// firstNonEmpty / firstNonBlank
 	//====================================================================================================
-	@Test void a82_firstNonEmpty() {
+	@Test
+	void a82_firstNonEmpty() {
 		assertEquals("test", firstNonEmpty("test"));
 		assertEquals("test", firstNonEmpty(null, "test"));
 		assertEquals("test", firstNonEmpty("", "test"));
@@ -2834,7 +2993,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(" ", firstNonEmpty(" "));
 	}
 
-	@Test void a83_firstNonBlank() {
+	@Test
+	void a83_firstNonBlank() {
 		assertEquals("test", firstNonBlank("test"));
 		assertEquals("test", firstNonBlank(null, "test"));
 		assertEquals("test", firstNonBlank("", "test"));
@@ -2850,7 +3010,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// cdlToList / cdlToSet
 	//====================================================================================================
-	@Test void a86_cdlToList() {
+	@Test
+	void a86_cdlToList() {
 		assertEquals(l("a", "b", "c"), cdlToList("a,b,c"));
 		assertEquals(l("a", "b", "c"), cdlToList(" a , b , c "));
 		assertEquals(l(), cdlToList(null));
@@ -2858,7 +3019,8 @@ class StringUtils_Test extends TestBase {
 		assertEquals(l("a"), cdlToList("a"));
 	}
 
-	@Test void a87_cdlToSet() {
+	@Test
+	void a87_cdlToSet() {
 		assertEquals(new LinkedHashSet<>(l("a", "b", "c")), cdlToSet("a,b,c"));
 		assertEquals(new LinkedHashSet<>(l("a", "b", "c")), cdlToSet(" a , b , c "));
 		assertEquals(set(), cdlToSet(null));
@@ -2869,13 +3031,15 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// join
 	//====================================================================================================
-	@Test void a88_join_varargs() {
+	@Test
+	void a88_join_varargs() {
 		assertEquals("a,b,c", join("a", "b", "c"));
 		assertEquals("a", join("a"));
 		assertEquals("", join());
 	}
 
-	@Test void a89_join_collection() {
+	@Test
+	void a89_join_collection() {
 		assertEquals("a,b,c", join(l("a", "b", "c")));
 		assertEquals("1,2,3", join(l(1, 2, 3)));
 		assertEquals("a", join(l("a")));
@@ -2885,7 +3049,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// contains / notContains
 	//====================================================================================================
-	@Test void a90_contains_strings() {
+	@Test
+	void a90_contains_strings() {
 		assertTrue(contains("test", "te"));
 		assertTrue(contains("test", "st"));
 		assertTrue(contains("test", "test"));
@@ -2896,7 +3061,8 @@ class StringUtils_Test extends TestBase {
 		assertFalse(contains("test", (String[])null));
 	}
 
-	@Test void a91_contains_chars() {
+	@Test
+	void a91_contains_chars() {
 		assertTrue(contains("test", 't'));
 		assertTrue(contains("test", 'e'));
 		assertTrue(contains("test", 't', 'x'));
@@ -2906,13 +3072,15 @@ class StringUtils_Test extends TestBase {
 		assertFalse(contains("test", (char[])null));
 	}
 
-	@Test void a92_notContains_strings() {
+	@Test
+	void a92_notContains_strings() {
 		assertFalse(notContains("test", "te"));
 		assertTrue(notContains("test", "xx"));
 		assertTrue(notContains(null, "test"));
 	}
 
-	@Test void a93_notContains_chars() {
+	@Test
+	void a93_notContains_chars() {
 		assertFalse(notContains("test", 't'));
 		assertTrue(notContains("test", 'x'));
 		assertTrue(notContains(null, 't'));
@@ -2921,7 +3089,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// stringSupplier
 	//====================================================================================================
-	@Test void a94_stringSupplier() {
+	@Test
+	void a94_stringSupplier() {
 		assertEquals("test", stringSupplier(() -> "test").get());
 		assertEquals("[1,2,3]", stringSupplier(() -> l(1, 2, 3)).get());
 		assertNull(stringSupplier(() -> null).get());
@@ -2930,19 +3099,21 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// readable
 	//====================================================================================================
-	@Test void a95_readable() {
+	@Test
+	void a95_readable() {
 		assertNull(readable(null));
 		assertEquals("[a,b,c]", readable(l("a", "b", "c")));
 		assertEquals("{foo=bar}", readable(m("foo", "bar")));
 		assertEquals("[1,2,3]", readable(ints(1, 2, 3)));
-	assertEquals("test", readable(opt("test")));
-	assertNull(readable(opte()));
+		assertEquals("test", readable(opt("test")));
+		assertNull(readable(opte()));
 	}
 
 	//====================================================================================================
 	// removeAll(String, String...)
 	//====================================================================================================
-	@Test void a96_removeAll() {
+	@Test
+	void a96_removeAll() {
 		assertNull(removeAll(null, "x"));
 		assertEquals("hello world test", removeAll("hello world test"));
 		assertEquals("hello world test", removeAll("hello world test", (String[])null));
@@ -2957,7 +3128,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// camelCase(String)
 	//====================================================================================================
-	@Test void a97_camelCase() {
+	@Test
+	void a97_camelCase() {
 		assertNull(camelCase(null));
 		assertEquals("", camelCase(""));
 		assertEquals("helloWorld", camelCase("hello world"));
@@ -2980,7 +3152,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// snakeCase(String)
 	//====================================================================================================
-	@Test void a98_snakeCase() {
+	@Test
+	void a98_snakeCase() {
 		assertNull(snakeCase(null));
 		assertEquals("", snakeCase(""));
 		assertEquals("hello_world", snakeCase("hello world"));
@@ -2998,7 +3171,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// kebabCase(String)
 	//====================================================================================================
-	@Test void a99_kebabCase() {
+	@Test
+	void a99_kebabCase() {
 		assertNull(kebabCase(null));
 		assertEquals("", kebabCase(""));
 		assertEquals("hello-world", kebabCase("hello world"));
@@ -3016,7 +3190,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// pascalCase(String)
 	//====================================================================================================
-	@Test void a100_pascalCase() {
+	@Test
+	void a100_pascalCase() {
 		assertNull(pascalCase(null));
 		assertEquals("", pascalCase(""));
 		assertEquals("HelloWorld", pascalCase("hello world"));
@@ -3034,7 +3209,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// titleCase(String)
 	//====================================================================================================
-	@Test void a101_titleCase() {
+	@Test
+	void a101_titleCase() {
 		assertNull(titleCase(null));
 		assertEquals("", titleCase(""));
 		assertEquals("Hello World", titleCase("hello world"));
@@ -3052,7 +3228,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// wrap(String, int)
 	//====================================================================================================
-	@Test void a102_wrap() {
+	@Test
+	void a102_wrap() {
 		assertNull(wrap(null, 10));
 		assertEquals("", wrap("", 10));
 		assertEquals("hello\nworld", wrap("hello world", 10));
@@ -3070,7 +3247,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// wrap(String, int, String)
 	//====================================================================================================
-	@Test void a103_wrapWithNewline() {
+	@Test
+	void a103_wrapWithNewline() {
 		assertNull(wrap(null, 10, "<br>"));
 		assertEquals("", wrap("", 10, "<br>"));
 		assertEquals("hello<br>world", wrap("hello world", 10, "<br>"));
@@ -3092,7 +3270,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// toStringArray(Collection<String>)
 	//====================================================================================================
-	@Test void a104_toStringArray() {
+	@Test
+	void a104_toStringArray() {
 		assertNull(toStringArray(null));
 		assertList(toStringArray(Collections.emptyList()));
 		assertList(toStringArray(l("a", "b", "c")), "a", "b", "c");
@@ -3115,7 +3294,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// filter(String[], Predicate<String>)
 	//====================================================================================================
-	@Test void a105_filter() {
+	@Test
+	void a105_filter() {
 		assertNull(filter(null, NOT_EMPTY));
 		assertList(filter(a(), NOT_EMPTY));
 		assertList(filter(a("foo", "", "bar", null, "baz"), NOT_EMPTY), "foo", "bar", "baz");
@@ -3130,7 +3310,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// map(String[], Function<String, String>)
 	//====================================================================================================
-	@Test void a106_mapped() {
+	@Test
+	void a106_mapped() {
 		assertNull(mapped(null, String::toUpperCase));
 		assertList(mapped(a(), String::toUpperCase));
 		assertList(mapped(a("foo", "bar", "baz"), String::toUpperCase), "FOO", "BAR", "BAZ");
@@ -3144,7 +3325,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// distinct(String[])
 	//====================================================================================================
-	@Test void a107_distinct() {
+	@Test
+	void a107_distinct() {
 		assertNull(distinct(null));
 		assertList(distinct(a()));
 		assertList(distinct(a("foo", "bar", "baz")), "foo", "bar", "baz");
@@ -3158,7 +3340,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// sort(String[])
 	//====================================================================================================
-	@Test void a108_sort() {
+	@Test
+	void a108_sort() {
 		assertNull(sort(null));
 		assertList(sort(a()));
 		assertList(sort(a("c", "a", "b")), "a", "b", "c");
@@ -3172,7 +3355,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// sortIgnoreCase(String[])
 	//====================================================================================================
-	@Test void a109_sortIgnoreCase() {
+	@Test
+	void a109_sortIgnoreCase() {
 		assertNull(sortIgnoreCase(null));
 		assertList(sortIgnoreCase(a()));
 		assertList(sortIgnoreCase(a("c", "a", "b")), "a", "b", "c");
@@ -3190,7 +3374,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// appendIfNotEmpty(StringBuilder, String)
 	//====================================================================================================
-	@Test void a110_appendIfNotEmpty() {
+	@Test
+	void a110_appendIfNotEmpty() {
 		var sb = new StringBuilder();
 		assertSame(sb, appendIfNotEmpty(sb, "hello"));
 		assertEquals("hello", sb.toString());
@@ -3214,7 +3399,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// appendIfNotBlank(StringBuilder, String)
 	//====================================================================================================
-	@Test void a111_appendIfNotBlank() {
+	@Test
+	void a111_appendIfNotBlank() {
 		var sb = new StringBuilder();
 		assertSame(sb, appendIfNotBlank(sb, "hello"));
 		assertEquals("hello", sb.toString());
@@ -3244,7 +3430,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// appendWithSeparator(StringBuilder, String, String)
 	//====================================================================================================
-	@Test void a112_appendWithSeparator() {
+	@Test
+	void a112_appendWithSeparator() {
 		var sb = new StringBuilder();
 		assertSame(sb, appendWithSeparator(sb, "first", ", "));
 		assertEquals("first", sb.toString());
@@ -3279,7 +3466,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// buildString(Consumer<StringBuilder>)
 	//====================================================================================================
-	@Test void a113_buildString() {
+	@Test
+	void a113_buildString() {
 		var result = buildString(sb -> {
 			sb.append("Hello");
 			sb.append(" ");
@@ -3316,7 +3504,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// String Constants
 	//====================================================================================================
-	@Test void a114_stringConstants() {
+	@Test
+	void a114_stringConstants() {
 		// EMPTY
 		assertEquals("", EMPTY);
 		assertTrue(EMPTY.isEmpty());
@@ -3369,7 +3558,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// intern(String)
 	//====================================================================================================
-	@Test void a115_intern() {
+	@Test
+	void a115_intern() {
 		assertNull(intern(null));
 
 		var s1 = new String("test");
@@ -3391,7 +3581,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// isInterned(String)
 	//====================================================================================================
-	@Test void a116_isInterned() {
+	@Test
+	void a116_isInterned() {
 		assertFalse(isInterned(null));
 
 		// String literals are automatically interned
@@ -3411,7 +3602,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// getStringSize(String)
 	//====================================================================================================
-	@Test void a117_getStringSize() {
+	@Test
+	void a117_getStringSize() {
 		assertEquals(0, getStringSize(null));
 		assertEquals(40, getStringSize("")); // 24 + 16 = 40 bytes overhead
 		assertEquals(50, getStringSize("hello")); // 40 + (5 * 2) = 50 bytes
@@ -3432,7 +3624,8 @@ class StringUtils_Test extends TestBase {
 	//====================================================================================================
 	// optimizeString(String)
 	//====================================================================================================
-	@Test void a118_optimizeString() {
+	@Test
+	void a118_optimizeString() {
 		assertNull(optimizeString(null));
 		assertNull(optimizeString("short")); // No suggestions for short strings
 
diff --git a/juneau-utest/src/test/java/org/apache/juneau/common/utils/Utils_Test.java b/juneau-utest/src/test/java/org/apache/juneau/common/utils/Utils_Test.java
index 59e4e62e1a..42438b8e5f 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/common/utils/Utils_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/common/utils/Utils_Test.java
@@ -38,5 +38,56 @@ class Utils_Test extends TestBase {
 
 		assertFalse(nn(null));
 	}
+
+	//====================================================================================================
+	// f(String, Object...)
+	//====================================================================================================
+	@Test
+	void a02_f() {
+		// Basic formatting
+		assertEquals("Hello John, you have 5 items", f("Hello %s, you have %d items", "John", 5));
+		assertEquals("Hello world", f("Hello %s", "world"));
+
+		// Floating point
+		assertEquals("Price: $19.99", f("Price: $%.2f", 19.99));
+		assertEquals("Value: 3.14", f("Value: %.2f", 3.14159));
+
+		// Multiple arguments
+		assertEquals("Name: John, Age: 30, Salary: $50000.00",
+			f("Name: %s, Age: %d, Salary: $%.2f", "John", 30, 50000.0));
+
+		// Null handling
+		assertEquals("Value: null", f("Value: %s", (String)null));
+		assertNull(f(null, "test"));
+		assertEquals("test", f("test"));
+	}
+
+	//====================================================================================================
+	// fs(String, Object...)
+	//====================================================================================================
+	@Test
+	void a03_fs() {
+		// Basic supplier
+		var supplier = fs("Hello %s, you have %d items", "John", 5);
+		assertNotNull(supplier);
+		assertEquals("Hello John, you have 5 items", supplier.get());
+
+		// Lazy evaluation - format only when get() is called
+		var lazySupplier = fs("Price: $%.2f", 19.99);
+		assertEquals("Price: $19.99", lazySupplier.get());
+
+		// Multiple calls return same result
+		var supplier2 = fs("Value: %s", "test");
+		assertEquals("Value: test", supplier2.get());
+		assertEquals("Value: test", supplier2.get());
+
+		// Null handling
+		var nullSupplier = fs(null, "test");
+		assertNull(nullSupplier.get());
+
+		// Empty pattern
+		var emptySupplier = fs("");
+		assertEquals("", emptySupplier.get());
+	}
 }