diff --git a/README.md b/README.md index 0678028..a8493d7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ article; each module's own README has that article's version table, quickstart, | [`annotation-processor`](annotation-processor/) | Custom Annotations and Annotation Processors in Java: Generate Code at Compile Time | | [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost | | [`jmh-basics`](jmh-basics/) | JMH Tutorial: Writing Java Microbenchmarks That Don't Lie | +| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet | ## License diff --git a/pom.xml b/pom.xml index c2422a2..11a8cf0 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ annotation-processor reflection jmh-basics + string-format diff --git a/string-format/README.md b/string-format/README.md new file mode 100644 index 0000000..a4f5b05 --- /dev/null +++ b/string-format/README.md @@ -0,0 +1,52 @@ +# string-format + +Companion code for the ankurm.com post *"String.format, formatted() and printf in Java: The Complete Cheat Sheet."* +Module in `java-core-examples`, the Java-core series. + +## Versions this was built and tested against + +| Component | Version | Notes | +|---|---|---| +| JDK | 25.0.5+8 (Temurin, LTS) | `String.format` is Java 5; `String.formatted()` arrived in Java 15; `Formatter` parsing was rewritten without regex in Java 17. | +| JUnit Jupiter | 5.11.0 | | +| Maven | 3.9.11 | | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn compile +scripts/run.sh IndianGroupingDemo # one demo +scripts/run-all.sh # regenerate every file in output/ +``` + +## What's in here + +| File | What it shows | +|---|---| +| `BasicsDemo.java` | Every part of `%[index$][flags][width][.precision]conversion`, changed one at a time. | +| `ReportTableDemo.java` | Left/right alignment, zero padding, truncation with `%.Ns`, a fixed-width invoice, `printf`. | +| `RoundingDemo.java` | `%f` rounds HALF_UP on the decimal string, so it disagrees with C `printf` and with `BigDecimal(double)`. | +| `IndianNumberFormat.java` + `IndianGroupingDemo.java` | **The main exhibit.** Every built-in route to lakh/crore grouping tried and shown to fail, then a 60-line formatter that works. | +| `LocaleDemo.java` | The same `%,.2f` under six locales; the default-locale trap; what is *not* localized. | +| `FormatErrorsDemo.java` | Twelve format strings that compile and then throw (or, for `"50% off"`, throw something surprising), with the real exception text. | +| `FormattedAndPrintfDemo.java` | `format` / `formatted` / `printf` / `Formatter` are one engine; text-block pairing; varargs and arrays. | +| `DateTimeDemo.java` | `%tF`, `%tT`, `%tA` on `java.time` values, localized names, and why `%tH` on a `LocalDate` throws. | +| `ConcatBenchmarkDemo.java` | Indicative cost of `+`, `StringBuilder`, `formatted`, `format`. A warmed plain loop, not JMH. | +| `StringFormatTest.java` | Pins every claim above as an assertion, 11/11 passing. | +| `scripts/printf_compare.c` | The same values through C `printf`, for the rounding comparison. | +| `output/01-11` | Captured runs of everything above. Timing in `08` drifts between runs. | + +## Notes worth knowing before reading the post + +- **`%,d` cannot produce lakh/crore grouping in any locale.** `en_IN` and `hi_IN` both print `123,456,789` + (`output/04`). The `,` flag takes one grouping size from `DecimalFormat.getGroupingSize()`, and a + `DecimalFormat` built from `#,##,##0` reports a grouping size of 3. +- **`"Discount 50% off"` throws `MissingFormatArgumentException`**, not an unknown-conversion error: + `% o` parses as the space flag plus the octal conversion (`output/06`). +- **`%f` on a `double` rounds the decimal string, `%f` on a `BigDecimal` rounds the exact value**, so + `2.675` formats as `2.68` one way and `2.67` the other (`output/03`). + +## License + +MIT - see the [repo-wide LICENSE](../LICENSE). diff --git a/string-format/output/01-basics.txt b/string-format/output/01-basics.txt new file mode 100644 index 0000000..fcae771 --- /dev/null +++ b/string-format/output/01-basics.txt @@ -0,0 +1,21 @@ +== conversions: what kind of value == +text | 42 | 3.141590 | ff | A | true +== width: minimum characters == +[ ab] [ab ] [ 42] [42 ] +== precision: digits after the point (f), or max chars (s) == +[3.14] [4] [ 3.142] [abc] +== flags == +[00042] [+42] [-42] [ 42] [(42)] [1,234,567] +[0xff] [010] [FF] [-003.142] +== argument index: reuse and reorder == +hello world +echo, echo and again echo +== %n and %% == +100% sure +next line +== %s accepts anything (calls String.valueOf / toString) == +[null] [7] [[1, 2]] [SHOUT] +== %b is not 'is this the string true' == +true true false false +== %c takes a char or a code point == +A A 😀 diff --git a/string-format/output/02-report-table.txt b/string-format/output/02-report-table.txt new file mode 100644 index 0000000..01936fa --- /dev/null +++ b/string-format/output/02-report-table.txt @@ -0,0 +1,13 @@ +Item Qty Price Total +------------------------------------------------ +Notebook 12 45.50 546.00 +Pen (box of 10) 3 120.00 360.00 +Stapler 1 350.00 350.00 +------------------------------------------------ +Grand total 1,256.00 + +== the same table with printf: it prints, it does not return a String == +Notebook | 12| + +== truncation: %.Ns cuts, width pads == +[Pen (b ] [ Pen (b] diff --git a/string-format/output/03-rounding.txt b/string-format/output/03-rounding.txt new file mode 100644 index 0000000..5d4e405 --- /dev/null +++ b/string-format/output/03-rounding.txt @@ -0,0 +1,17 @@ +value %.1f %.2f exact binary value BigDecimal(HALF_EVEN,2) +0.125 0.1 0.13 0.125 0.12 +0.25 0.3 0.25 0.25 0.25 +0.35 0.4 0.35 0.34999999999999997779 0.35 +2.675 2.7 2.68 2.67499999999999982236 2.67 +1.005 1.0 1.01 1.00499999999999989341 1.00 +0.5 0.5 0.50 0.5 0.50 +1.5 1.5 1.50 1.5 1.50 +2.5 2.5 2.50 2.5 2.50 + +== %.0f on halves: HALF_UP, away from zero == +1 2 3 -3 + +== same number, three routes: %f rounds what it is given == +double 2.675 -> 2.68 +new BigDecimal("2.675") -> 2.68 +new BigDecimal(2.675) -> 2.67 diff --git a/string-format/output/04-indian-grouping.txt b/string-format/output/04-indian-grouping.txt new file mode 100644 index 0000000..b3ec78b --- /dev/null +++ b/string-format/output/04-indian-grouping.txt @@ -0,0 +1,30 @@ +== what the JDK gives for 123456789 == +String.format(en_IN, "%,d") : 123,456,789 +String.format(hi_IN, "%,d") : 123,456,789 +NumberFormat.getInstance(en_IN) : 123,456,789 +NumberFormat currency (en_IN) : ₹123,456,789.50 +CLDR pattern for en_IN : #,##0.### + +== a DecimalFormat pattern that LOOKS like it should work == +pattern #,##,##0 -> grouping size : 3 +pattern #,##,##0 -> output : 123,456,789 + +== compact formatting knows crore but drops the digits == +compact (default rounding) : 12Cr +compact (2 fraction digits) : 12.35Cr + +== IndianNumberFormat == + 0 -> 0 + 999 -> 999 + 1,000 -> 1,000 + 12,345 -> 12,345 + 123,456 -> 1,23,456 + 1,234,567 -> 12,34,567 + 123,456,789 -> 12,34,56,789 + -123,456,789 -> -12,34,56,789 +12,34,567.89 +-1,00,000.00 +Rs. 12,34,567.50 + +== plugging it into a format string through %s == +Balance 9,87,65,432.10 diff --git a/string-format/output/05-locale.txt b/string-format/output/05-locale.txt new file mode 100644 index 0000000..629924f --- /dev/null +++ b/string-format/output/05-locale.txt @@ -0,0 +1,17 @@ +default FORMAT locale: en_US + +== %,.2f of 1234567.891 == +en-US 1,234,567.89 +de-DE 1.234.567,89 +fr-FR 1^234^567,89 +en-IN 1,234,567.89 +de-CH 1’234’567.89 +und 1,234,567.89 + +== the bug: changing the default locale changes %.2f == +default (en_US) : 1234.50 +default (de_DE) : 1234,50 +explicit ROOT : 1234.50 + +== not localized: hex, NaN, Infinity == +ff NaN Infinity diff --git a/string-format/output/06-format-errors.txt b/string-format/output/06-format-errors.txt new file mode 100644 index 0000000..95828b6 --- /dev/null +++ b/string-format/output/06-format-errors.txt @@ -0,0 +1,12 @@ +wrong type for %d -> IllegalFormatConversionException: d != java.lang.String +too few arguments -> MissingFormatArgumentException: Format specifier '%s' +unknown conversion -> UnknownFormatConversionException: Conversion = 'q' +precision on an integer -> IllegalFormatPrecisionException: 2 +flag not valid for %x , -> FormatFlagsConversionMismatchException: Conversion = x, Flags = , +left-justify without width -> MissingFormatWidthException: %-d +zero pad AND left-justify -> IllegalFormatFlagsException: Flags = '-0' +width on %n -> IllegalFormatWidthException: 5 +double passed to %d -> IllegalFormatConversionException: d != java.lang.Double +int passed to %f -> IllegalFormatConversionException: f != java.lang.Integer +a stray % in user text -> MissingFormatArgumentException: Format specifier '% o' +extra arguments (no error) -> OK: a diff --git a/string-format/output/07-formatted-printf.txt b/string-format/output/07-formatted-printf.txt new file mode 100644 index 0000000..6a06f85 --- /dev/null +++ b/string-format/output/07-formatted-printf.txt @@ -0,0 +1,22 @@ +== four spellings, one result == +Hi Asha, 3 items, Rs 1499.50 +Hi Asha, 3 items, Rs 1499.50 +Hi Asha, 3 items, Rs 1499.50 +Hi Asha, 3 items, Rs 1499.50 +format == formatted == Formatter: true + +== formatted() on a text block == +Invoice for Asha + items : 3 + total : 1,499.50 + +== printf returns the PrintStream, so calls chain == +one two three + +== %n vs \n == +true on this platform (line.separator is \n) + +== an array argument: spread as the varargs, unless you cast it to Object == +x-1 +p-q +true diff --git a/string-format/output/08-concat-benchmark.txt b/string-format/output/08-concat-benchmark.txt new file mode 100644 index 0000000..f8ab220 --- /dev/null +++ b/string-format/output/08-concat-benchmark.txt @@ -0,0 +1,5 @@ +JDK 25.0.5, 2000000 iterations per variant, best of 5 rounds +PLUS 52.6 ns/op +BUILDER 66.3 ns/op +FORMATTED 292.1 ns/op +FORMAT 279.5 ns/op diff --git a/string-format/output/09-tests.txt b/string-format/output/09-tests.txt new file mode 100644 index 0000000..913ac01 --- /dev/null +++ b/string-format/output/09-tests.txt @@ -0,0 +1,3 @@ +[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.153 s -- in com.ankurm.stringformat.StringFormatTest +[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS diff --git a/string-format/output/10-c-printf.txt b/string-format/output/10-c-printf.txt new file mode 100644 index 0000000..935fcf9 --- /dev/null +++ b/string-format/output/10-c-printf.txt @@ -0,0 +1,4 @@ +C printf %.2f of 2.675 : 2.67 +C printf %.1f of 0.35 : 0.3 +C printf %.2f of 1.005 : 1.00 +C printf %.2f of 0.125 : 0.12 diff --git a/string-format/output/11-datetime.txt b/string-format/output/11-datetime.txt new file mode 100644 index 0000000..0c3276e --- /dev/null +++ b/string-format/output/11-datetime.txt @@ -0,0 +1,14 @@ +== %t suffixes on a LocalDateTime == +2026-11-12 | 09:05:07 | 09:05 | 11/12/26 +Thursday, 12 November 2026 +09:05 AM + +== the same suffixes, other locales (names are localized, numbers are not) == +Donnerstag, 12. November 2026 +jeudi 12 novembre 2026 + +== a LocalDate has no hour == +IllegalFormatConversionException: H != java.time.LocalDate + +== DateTimeFormatter does the same job with a reusable, immutable object == +12 Nov 2026 09:05 diff --git a/string-format/pom.xml b/string-format/pom.xml new file mode 100644 index 0000000..9bb1316 --- /dev/null +++ b/string-format/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + string-format + string-format + String.format, formatted() and printf: specifier anatomy, padding and alignment, rounding, locales, Indian lakh/crore grouping (which Formatter cannot do), the exceptions it throws, and what it costs against concatenation. + + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/string-format/scripts/printf_compare.c b/string-format/scripts/printf_compare.c new file mode 100644 index 0000000..85dfded --- /dev/null +++ b/string-format/scripts/printf_compare.c @@ -0,0 +1,9 @@ +/* The same four values RoundingDemo formats, through C's printf, for comparison with Java's %f. */ +#include +int main(void) { + printf("C printf %%.2f of 2.675 : %.2f\n", 2.675); + printf("C printf %%.1f of 0.35 : %.1f\n", 0.35); + printf("C printf %%.2f of 1.005 : %.2f\n", 1.005); + printf("C printf %%.2f of 0.125 : %.2f\n", 0.125); + return 0; +} diff --git a/string-format/scripts/run-all.sh b/string-format/scripts/run-all.sh new file mode 100755 index 0000000..eed2145 --- /dev/null +++ b/string-format/scripts/run-all.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Timing output (07) is indicative and drifts between runs. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; } +run BasicsDemo 01-basics +run ReportTableDemo 02-report-table +run RoundingDemo 03-rounding +run IndianGroupingDemo 04-indian-grouping +run LocaleDemo 05-locale +run FormatErrorsDemo 06-format-errors +run FormattedAndPrintfDemo 07-formatted-printf +run ConcatBenchmarkDemo 08-concat-benchmark +if command -v gcc >/dev/null; then + gcc scripts/printf_compare.c -o /tmp/printf_compare && /tmp/printf_compare > output/10-c-printf.txt +fi +run DateTimeDemo 11-datetime +mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt +echo "regenerated:"; ls output diff --git a/string-format/scripts/run.sh b/string-format/scripts/run.sh new file mode 100755 index 0000000..ee53738 --- /dev/null +++ b/string-format/scripts/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run one demo: scripts/run.sh BasicsDemo +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.${1:?demo class name}" diff --git a/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java b/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java new file mode 100644 index 0000000..fd089f5 --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java @@ -0,0 +1,39 @@ +package com.ankurm.stringformat; + +/** + * The anatomy of a format specifier: %[argument_index$][flags][width][.precision]conversion. + * Each line below changes exactly one part of the specifier so the effect is visible. + */ +public class BasicsDemo { + + public static void main(String[] args) { + System.out.println("== conversions: what kind of value =="); + System.out.println(String.format("%s | %d | %f | %x | %c | %b", "text", 42, 3.14159, 255, 'A', true)); + + System.out.println("== width: minimum characters =="); + System.out.println(String.format("[%8s] [%-8s] [%8d] [%-8d]", "ab", "ab", 42, 42)); + + System.out.println("== precision: digits after the point (f), or max chars (s) =="); + System.out.println(String.format("[%.2f] [%.0f] [%8.3f] [%.3s]", 3.14159, 3.5, 3.14159, "abcdef")); + + System.out.println("== flags =="); + System.out.println(String.format("[%05d] [%+d] [%+d] [% d] [%(d] [%,d]", 42, 42, -42, 42, -42, 1234567)); + System.out.println(String.format("[%#x] [%#o] [%X] [%08.3f]", 255, 8, 255, -3.14159)); + + System.out.println("== argument index: reuse and reorder =="); + System.out.println(String.format("%2$s %1$s", "world", "hello")); + System.out.println(String.format("%1$s, %1$s and again % "user=" + i + " score=" + (i * 0.5); + case BUILDER -> new StringBuilder().append("user=").append(i).append(" score=").append(i * 0.5).toString(); + case FORMATTED -> "user=%d score=%.1f".formatted(i, i * 0.5); + case FORMAT -> String.format("user=%d score=%.1f", i, i * 0.5); + }; + acc += s.length(); + } + sink = acc; + return System.nanoTime() - t0; + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java b/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java new file mode 100644 index 0000000..d0856ee --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java @@ -0,0 +1,41 @@ +package com.ankurm.stringformat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * The %t conversions format java.time values directly. They work, but they are locale-sensitive + * and a LocalDate has no time fields, so asking it for an hour is a runtime error. + */ +public class DateTimeDemo { + + public static void main(String[] args) { + LocalDateTime when = LocalDateTime.of(2026, 11, 12, 9, 5, 7); + LocalDate day = when.toLocalDate(); + + System.out.println("== %t suffixes on a LocalDateTime =="); + System.out.println(String.format(Locale.US, "%tF | %tT | %tR | %tD", when, when, when, when)); + System.out.println(String.format(Locale.US, "%tA, % String.format("%d", "42")); + run("too few arguments", () -> String.format("%s and %s", "one")); + run("unknown conversion", () -> String.format("%q", 1)); + run("precision on an integer", () -> String.format("%.2d", 5)); + run("flag not valid for %x ,", () -> String.format("%,x", 255)); + run("left-justify without width", () -> String.format("%-d", 5)); + run("zero pad AND left-justify", () -> String.format("%-08d", 5)); + run("width on %n", () -> String.format("%5n")); + run("double passed to %d", () -> String.format("%d", 3.0)); + run("int passed to %f", () -> String.format("%f", 3)); + run("a stray % in user text", () -> String.format("Discount 50% off")); + run("extra arguments (no error)", () -> String.format("%s", "a", "b", "c")); + } + + private static void run(String label, Supplier call) { + try { + System.out.println(String.format("%-28s -> OK: %s", label, call.get())); + } catch (RuntimeException e) { + System.out.println(String.format("%-28s -> %s: %s", label, e.getClass().getSimpleName(), e.getMessage())); + } + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java b/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java new file mode 100644 index 0000000..ea30538 --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java @@ -0,0 +1,53 @@ +package com.ankurm.stringformat; + +import java.util.Formatter; + +/** + * The four ways to reach the same engine: String.format, String.formatted, PrintStream.printf, + * and java.util.Formatter. Plus the text-block pairing that makes formatted() worth using. + */ +public class FormattedAndPrintfDemo { + + public static void main(String[] args) { + String name = "Asha"; + int items = 3; + double total = 1499.5; + + System.out.println("== four spellings, one result =="); + String a = String.format("Hi %s, %d items, Rs %.2f", name, items, total); + String b = "Hi %s, %d items, Rs %.2f".formatted(name, items, total); + StringBuilder sb = new StringBuilder(); + new Formatter(sb).format("Hi %s, %d items, Rs %.2f", name, items, total); + System.out.println(a); + System.out.println(b); + System.out.println(sb); + System.out.printf("Hi %s, %d items, Rs %.2f%n", name, items, total); + System.out.println("format == formatted == Formatter: " + (a.equals(b) && b.equals(sb.toString()))); + + System.out.println(); + System.out.println("== formatted() on a text block =="); + String invoice = """ + Invoice for %s + items : %5d + total : %,10.2f + """.formatted(name, items, total); + System.out.print(invoice); + + System.out.println(); + System.out.println("== printf returns the PrintStream, so calls chain =="); + System.out.printf("%s ", "one").printf("%s ", "two").printf("%s%n", "three"); + + System.out.println(); + System.out.println("== %n vs \\n =="); + System.out.println(String.format("a%nb").equals("a\nb") + " on this platform (line.separator is " + + (System.lineSeparator().equals("\n") ? "\\n" : "not \\n") + ")"); + + System.out.println(); + System.out.println("== an array argument: spread as the varargs, unless you cast it to Object =="); + Object[] row = {"x", 1}; + String[] strings = {"p", "q"}; + System.out.println(String.format("%s-%s", row)); + System.out.println(String.format("%s-%s", strings)); + System.out.println(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@")); + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java b/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java new file mode 100644 index 0000000..73dcbef --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java @@ -0,0 +1,52 @@ +package com.ankurm.stringformat; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Locale; + +/** + * Tries every built-in route to lakh/crore grouping, shows that none of them produce it, + * then uses IndianNumberFormat. + */ +public class IndianGroupingDemo { + + public static void main(String[] args) { + Locale enIN = Locale.of("en", "IN"); + Locale hiIN = Locale.of("hi", "IN"); + long amount = 12_34_56_789L; // 12 crore 34 lakh 56 thousand 789 + + System.out.println("== what the JDK gives for 123456789 =="); + System.out.println("String.format(en_IN, \"%,d\") : " + String.format(enIN, "%,d", amount)); + System.out.println("String.format(hi_IN, \"%,d\") : " + String.format(hiIN, "%,d", amount)); + System.out.println("NumberFormat.getInstance(en_IN) : " + NumberFormat.getInstance(enIN).format(amount)); + System.out.println("NumberFormat currency (en_IN) : " + NumberFormat.getCurrencyInstance(enIN).format(amount + 0.5)); + System.out.println("CLDR pattern for en_IN : " + ((DecimalFormat) NumberFormat.getInstance(enIN)).toPattern()); + + System.out.println(); + System.out.println("== a DecimalFormat pattern that LOOKS like it should work =="); + DecimalFormat df = new DecimalFormat("#,##,##0"); + System.out.println("pattern #,##,##0 -> grouping size : " + df.getGroupingSize()); + System.out.println("pattern #,##,##0 -> output : " + df.format(amount)); + + System.out.println(); + System.out.println("== compact formatting knows crore but drops the digits =="); + NumberFormat compact = NumberFormat.getCompactNumberInstance(enIN, NumberFormat.Style.SHORT); + System.out.println("compact (default rounding) : " + compact.format(amount)); + compact.setMaximumFractionDigits(2); + System.out.println("compact (2 fraction digits) : " + compact.format(amount)); + + System.out.println(); + System.out.println("== IndianNumberFormat =="); + for (long v : new long[]{0, 999, 1_000, 12_345, 1_23_456, 12_34_567, 12_34_56_789L, -12_34_56_789L}) { + System.out.println(String.format("%,15d -> %s", v, IndianNumberFormat.format(v))); + } + System.out.println(IndianNumberFormat.format(new BigDecimal("1234567.891"), 2)); + System.out.println(IndianNumberFormat.format(new BigDecimal("-99999.995"), 2)); + System.out.println("Rs. " + IndianNumberFormat.format(1234567.5, 2)); + + System.out.println(); + System.out.println("== plugging it into a format string through %s =="); + System.out.println(String.format("%-12s %20s", "Balance", IndianNumberFormat.format(new BigDecimal("98765432.10"), 2))); + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java b/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java new file mode 100644 index 0000000..81cbfae --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java @@ -0,0 +1,62 @@ +package com.ankurm.stringformat; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Lakh/crore digit grouping (12,34,56,789) - the Indian system: the last three digits, then groups + * of two. java.util.Formatter cannot produce it because its ',' flag takes ONE grouping size from + * DecimalFormat.getGroupingSize(), and DecimalFormat keeps only the last group length of a pattern. + * See IndianGroupingDemo for the output that proves it. + */ +public final class IndianNumberFormat { + + private IndianNumberFormat() {} + + /** Groups a whole number: 12345678 -> "1,23,45,678". */ + public static String format(long value) { + String digits = Long.toString(value); + boolean negative = digits.startsWith("-"); + if (negative) { + digits = digits.substring(1); + } + return (negative ? "-" : "") + group(digits); + } + + /** Groups the integer part of a decimal and rounds the fraction HALF_UP: 1234567.891, 2 -> "12,34,567.89". */ + public static String format(BigDecimal value, int fractionDigits) { + BigDecimal rounded = value.setScale(fractionDigits, RoundingMode.HALF_UP); + String plain = rounded.abs().toPlainString(); + int dot = plain.indexOf('.'); + String integerPart = dot < 0 ? plain : plain.substring(0, dot); + String fraction = dot < 0 ? "" : plain.substring(dot); + String sign = rounded.signum() < 0 ? "-" : ""; + return sign + group(integerPart) + fraction; + } + + /** Convenience for doubles - goes through BigDecimal.valueOf so it rounds the way %f does. */ + public static String format(double value, int fractionDigits) { + return format(BigDecimal.valueOf(value), fractionDigits); + } + + private static String group(String digits) { + int n = digits.length(); + if (n <= 3) { + return digits; + } + String head = digits.substring(0, n - 3); + String tail = digits.substring(n - 3); + StringBuilder sb = new StringBuilder(); + int first = head.length() % 2; // leading group of 1 or 2 digits + if (first == 1) { + sb.append(head.charAt(0)); + } + for (int i = first; i < head.length(); i += 2) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(head, i, i + 2); + } + return sb.append(',').append(tail).toString(); + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java b/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java new file mode 100644 index 0000000..5ddc8b7 --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java @@ -0,0 +1,39 @@ +package com.ankurm.stringformat; + +import java.util.Locale; + +/** + * The same format string produces different text under different locales. String.format(fmt, ...) + * silently uses the default FORMAT locale; String.format(Locale, fmt, ...) does not. + */ +public class LocaleDemo { + + public static void main(String[] args) { + double v = 1234567.891; + System.out.println("default FORMAT locale: " + Locale.getDefault(Locale.Category.FORMAT)); + System.out.println(); + System.out.println("== %,.2f of 1234567.891 =="); + for (Locale l : new Locale[]{Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.of("en", "IN"), Locale.of("de", "CH"), Locale.ROOT}) { + String s = String.format(l, "%,.2f", v); + System.out.println(String.format("%-8s %s", l.toLanguageTag().isEmpty() ? "ROOT" : l.toLanguageTag(), visible(s))); + } + + System.out.println(); + System.out.println("== the bug: changing the default locale changes %.2f =="); + Locale saved = Locale.getDefault(Locale.Category.FORMAT); + System.out.println("default (" + saved + ") : " + String.format("%.2f", 1234.5)); + Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY); + System.out.println("default (de_DE) : " + String.format("%.2f", 1234.5)); + System.out.println("explicit ROOT : " + String.format(Locale.ROOT, "%.2f", 1234.5)); + Locale.setDefault(Locale.Category.FORMAT, saved); + + System.out.println(); + System.out.println("== not localized: hex, NaN, Infinity =="); + System.out.println(String.format(Locale.GERMANY, "%x %f %f", 255, Double.NaN, Double.POSITIVE_INFINITY)); + } + + /** Makes non-breaking and narrow no-break spaces visible so they are not mistaken for plain spaces. */ + private static String visible(String s) { + return s.replace(' ', '~').replace(' ', '^'); + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java b/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java new file mode 100644 index 0000000..9a28133 --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java @@ -0,0 +1,37 @@ +package com.ankurm.stringformat; + +/** + * Padding and alignment in practice: a fixed-width table. Text is left-aligned with a negative + * width, numbers are right-aligned so the decimal points line up. + */ +public class ReportTableDemo { + + record Line(String item, int qty, double price) {} + + public static void main(String[] args) { + var lines = java.util.List.of( + new Line("Notebook", 12, 45.5), + new Line("Pen (box of 10)", 3, 120.0), + new Line("Stapler", 1, 349.999)); + + String header = String.format("%-18s %5s %10s %12s", "Item", "Qty", "Price", "Total"); + System.out.println(header); + System.out.println("-".repeat(header.length())); + double grand = 0; + for (Line l : lines) { + double total = l.qty() * l.price(); + grand += total; + System.out.println(String.format("%-18s %5d %10.2f %,12.2f", l.item(), l.qty(), l.price(), total)); + } + System.out.println("-".repeat(header.length())); + System.out.println(String.format("%-18s %5s %10s %,12.2f", "Grand total", "", "", grand)); + + System.out.println(); + System.out.println("== the same table with printf: it prints, it does not return a String =="); + System.out.printf("%-18s|%5d|%n", "Notebook", 12); + + System.out.println(); + System.out.println("== truncation: %.Ns cuts, width pads =="); + System.out.println(String.format("[%-10.6s] [%10.6s]", "Pen (box of 10)", "Pen (box of 10)")); + } +} diff --git a/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java b/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java new file mode 100644 index 0000000..7c39f14 --- /dev/null +++ b/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java @@ -0,0 +1,34 @@ +package com.ankurm.stringformat; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * %f rounds HALF_UP on the decimal string Double.toString produces, not on the exact binary value. + * That is why it can disagree with C's printf and with BigDecimal(double). + */ +public class RoundingDemo { + + public static void main(String[] args) { + double[] values = {0.125, 0.25, 0.35, 2.675, 1.005, 0.5, 1.5, 2.5}; + System.out.println(String.format("%-8s %-8s %-8s %-22s %s", "value", "%.1f", "%.2f", "exact binary value", "BigDecimal(HALF_EVEN,2)")); + for (double v : values) { + System.out.println(String.format("%-8s %-8s %-8s %-22s %s", + v, + String.format("%.1f", v), + String.format("%.2f", v), + new BigDecimal(v).toPlainString().substring(0, Math.min(22, new BigDecimal(v).toPlainString().length())), + new BigDecimal(v).setScale(2, RoundingMode.HALF_EVEN))); + } + + System.out.println(); + System.out.println("== %.0f on halves: HALF_UP, away from zero =="); + System.out.println(String.format("%.0f %.0f %.0f %.0f", 0.5, 1.5, 2.5, -2.5)); + + System.out.println(); + System.out.println("== same number, three routes: %f rounds what it is given =="); + System.out.println(String.format("double 2.675 -> %.2f", 2.675)); + System.out.println(String.format("new BigDecimal(\"2.675\") -> %.2f", new BigDecimal("2.675"))); + System.out.println(String.format("new BigDecimal(2.675) -> %.2f", new BigDecimal(2.675))); + } +} diff --git a/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java b/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java new file mode 100644 index 0000000..e83ecfb --- /dev/null +++ b/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java @@ -0,0 +1,128 @@ +package com.ankurm.stringformat; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.*; +import org.junit.jupiter.api.Test; + +/** Pins every claim the post makes as an assertion, so a JDK change that breaks one fails the build. */ +class StringFormatTest { + + @Test + void widthFlagsAndPrecision() { + assertEquals("[ ab] [ab ]", String.format("[%8s] [%-8s]", "ab", "ab")); + assertEquals("00042", String.format("%05d", 42)); + assertEquals("3.14", String.format("%.2f", 3.14159)); + assertEquals("abc", String.format("%.3s", "abcdef")); + assertEquals("world hello", String.format("%2$s %1$s", "hello", "world")); + assertEquals("a a a", String.format("%1$s %1$s % String.format("%d", "42")); + assertThrows(IllegalFormatConversionException.class, () -> String.format("%d", 3.0)); + assertThrows(MissingFormatArgumentException.class, () -> String.format("%s %s", "one")); + assertThrows(UnknownFormatConversionException.class, () -> String.format("%q", 1)); + assertThrows(IllegalFormatPrecisionException.class, () -> String.format("%.2d", 5)); + assertThrows(FormatFlagsConversionMismatchException.class, () -> String.format("%,x", 255)); + assertThrows(MissingFormatWidthException.class, () -> String.format("%-d", 5)); + var stray = assertThrows(MissingFormatArgumentException.class, () -> String.format("Discount 50% off")); + assertEquals("Format specifier '% o'", stray.getMessage()); // "% o" = space flag + octal conversion + assertEquals("a", String.format("%s", "a", "b", "c")); // extra arguments are ignored silently + } + + @Test + void arraysSpreadIntoVarargsUnlessCastToObject() { + Object[] objects = {"x", 1}; + assertEquals("x-1", String.format("%s-%s", objects)); + String[] strings = {"p", "q"}; + assertEquals("p-q", String.format("%s-%s", strings)); + assertTrue(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@")); + } + + @Test + void everyFormatErrorIsAnIllegalFormatException() { + for (Runnable bad : new Runnable[]{ + () -> String.format("%d", "42"), + () -> String.format("%s %s", "one"), + () -> String.format("%q", 1), + () -> String.format("%.2d", 5), + () -> String.format("%,x", 255), + () -> String.format("%-d", 5), + () -> String.format("%-08d", 5), + () -> String.format("%5n")}) { + assertThrows(IllegalFormatException.class, bad::run); + } + } + + @Test + void dateTimeSuffixes() { + var when = java.time.LocalDateTime.of(2026, 11, 12, 9, 5, 7); + assertEquals("2026-11-12 | 09:05:07", String.format(Locale.US, "%tF | % String.format("%tH", when.toLocalDate())); + } +}