This is an automated email from the ASF dual-hosted git repository. mattsicker pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
The following commit(s) were added to refs/heads/main by this push: new ebfc571f7d Use garbage-free formatter for `s` and `S` patterns (#3338) (#3860) ebfc571f7d is described below commit ebfc571f7db6bc78eda12df5aaa9eb4e9178174d Author: Matt Sicker <mattsic...@apache.org> AuthorDate: Thu Jul 31 13:08:22 2025 -0500 Use garbage-free formatter for `s` and `S` patterns (#3338) (#3860) This PR improves #3139, by introducing a new `InstantPatternFormatter` for patterns of the form "ss\.S{n}". Unlike the previous formatter based on `DateTimeFormatter`, the formatter is garbage-free. We also simplify the merging algorithm for pattern formatter factories, by moving the merging logic to the pattern formatter factories themselves. This PR does not contain a separate change log entry, since #3139 has not been published yet. Fixes #3337. Co-authored-by: Piotr P. Karwasz <piotr.git...@karwasz.org> Co-authored-by: Volkan Yazıcı <vol...@yazi.ci> --- .../InstantPatternDynamicFormatterTest.java | 175 ++--- ...stantPatternThreadLocalCachedFormatterTest.java | 2 +- .../instant/InstantPatternDynamicFormatter.java | 713 ++++++++++----------- .../ROOT/pages/manual/json-template-layout.adoc | 7 + .../modules/ROOT/pages/manual/pattern-layout.adoc | 4 +- 5 files changed, 459 insertions(+), 442 deletions(-) diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatterTest.java index ddb8c31103..d3f80a3607 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatterTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatterTest.java @@ -32,9 +32,9 @@ import java.util.TimeZone; import java.util.stream.IntStream; import java.util.stream.Stream; import org.apache.logging.log4j.core.time.MutableInstant; -import org.apache.logging.log4j.core.util.internal.instant.InstantPatternDynamicFormatter.CompositePatternSequence; import org.apache.logging.log4j.core.util.internal.instant.InstantPatternDynamicFormatter.DynamicPatternSequence; import org.apache.logging.log4j.core.util.internal.instant.InstantPatternDynamicFormatter.PatternSequence; +import org.apache.logging.log4j.core.util.internal.instant.InstantPatternDynamicFormatter.SecondPatternSequence; import org.apache.logging.log4j.core.util.internal.instant.InstantPatternDynamicFormatter.StaticPatternSequence; import org.apache.logging.log4j.util.Constants; import org.junit.jupiter.params.ParameterizedTest; @@ -55,26 +55,23 @@ public class InstantPatternDynamicFormatterTest { static List<Arguments> sequencingTestCases() { final List<Arguments> testCases = new ArrayList<>(); + // Merged constants + testCases.add(Arguments.of(":'foo',", ChronoUnit.DAYS, singletonList(new StaticPatternSequence(":foo,")))); + // `SSSX` should be treated constant for daily updates - testCases.add(Arguments.of("SSSX", ChronoUnit.DAYS, singletonList(pCom(pDyn("SSS"), pDyn("X"))))); + testCases.add(Arguments.of("SSSX", ChronoUnit.DAYS, asList(pMilliSec(), pDyn("X")))); // `yyyyMMddHHmmssSSSX` instant cache updated hourly testCases.add(Arguments.of( "yyyyMMddHHmmssSSSX", ChronoUnit.HOURS, - asList( - pCom(pDyn("yyyy"), pDyn("MM"), pDyn("dd"), pDyn("HH")), - pCom(pDyn("mm"), pDyn("ss"), pDyn("SSS")), - pDyn("X")))); + asList(pDyn("yyyyMMddHH", ChronoUnit.HOURS), pDyn("mm"), pSec("", 3), pDyn("X")))); // `yyyyMMddHHmmssSSSX` instant cache updated per minute testCases.add(Arguments.of( "yyyyMMddHHmmssSSSX", ChronoUnit.MINUTES, - asList( - pCom(pDyn("yyyy"), pDyn("MM"), pDyn("dd"), pDyn("HH"), pDyn("mm")), - pCom(pDyn("ss"), pDyn("SSS")), - pDyn("X")))); + asList(pDyn("yyyyMMddHHmm", ChronoUnit.MINUTES), pSec("", 3), pDyn("X")))); // ISO9601 instant cache updated daily final String iso8601InstantPattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX"; @@ -82,77 +79,50 @@ public class InstantPatternDynamicFormatterTest { iso8601InstantPattern, ChronoUnit.DAYS, asList( - pCom(pDyn("yyyy"), pSta("-"), pDyn("MM"), pSta("-"), pDyn("dd"), pSta("T")), - pCom( - pDyn("HH"), - pSta(":"), - pDyn("mm"), - pSta(":"), - pDyn("ss"), - pSta("."), - pDyn("SSS"), - pDyn("X"))))); + pDyn("yyyy'-'MM'-'dd'T'", ChronoUnit.DAYS), + pDyn("HH':'mm':'", ChronoUnit.MINUTES), + pSec(".", 3), + pDyn("X")))); // ISO9601 instant cache updated per minute testCases.add(Arguments.of( iso8601InstantPattern, ChronoUnit.MINUTES, - asList( - pCom( - pDyn("yyyy"), - pSta("-"), - pDyn("MM"), - pSta("-"), - pDyn("dd"), - pSta("T"), - pDyn("HH"), - pSta(":"), - pDyn("mm"), - pSta(":")), - pCom(pDyn("ss"), pSta("."), pDyn("SSS")), - pDyn("X")))); + asList(pDyn("yyyy'-'MM'-'dd'T'HH':'mm':'", ChronoUnit.MINUTES), pSec(".", 3), pDyn("X")))); // ISO9601 instant cache updated per second testCases.add(Arguments.of( iso8601InstantPattern, ChronoUnit.SECONDS, - asList( - pCom( - pDyn("yyyy"), - pSta("-"), - pDyn("MM"), - pSta("-"), - pDyn("dd"), - pSta("T"), - pDyn("HH"), - pSta(":"), - pDyn("mm"), - pSta(":"), - pDyn("ss"), - pSta(".")), - pDyn("SSS"), - pDyn("X")))); + asList(pDyn("yyyy'-'MM'-'dd'T'HH':'mm':'", ChronoUnit.MINUTES), pSec(".", 3), pDyn("X")))); + + // Seconds and micros + testCases.add(Arguments.of( + "HH:mm:ss.SSSSSS", ChronoUnit.MINUTES, asList(pDyn("HH':'mm':'", ChronoUnit.MINUTES), pSec(".", 6)))); return testCases; } - private static CompositePatternSequence pCom(final PatternSequence... sequences) { - return new CompositePatternSequence(asList(sequences)); + private static DynamicPatternSequence pDyn(final String singlePattern) { + return new DynamicPatternSequence(singlePattern); + } + + private static DynamicPatternSequence pDyn(final String pattern, final ChronoUnit precision) { + return new DynamicPatternSequence(pattern, precision); } - private static DynamicPatternSequence pDyn(final String pattern) { - return new DynamicPatternSequence(pattern); + private static SecondPatternSequence pSec(String separator, int fractionalDigits) { + return new SecondPatternSequence(true, separator, fractionalDigits); } - private static StaticPatternSequence pSta(final String literal) { - return new StaticPatternSequence(literal); + private static SecondPatternSequence pMilliSec() { + return new SecondPatternSequence(false, "", 3); } @ParameterizedTest @ValueSource( strings = { // Basics - "S", "SSSSSSS", "SSSSSSSSS", "n", @@ -163,8 +133,7 @@ public class InstantPatternDynamicFormatterTest { "yyyy-MM-dd HH:mm:ss,SSSSSSS", "yyyy-MM-dd HH:mm:ss,SSSSSSSS", "yyyy-MM-dd HH:mm:ss,SSSSSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS", - "yyyy-MM-dd'T'HH:mm:ss.SXXX" + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS" }) void should_recognize_patterns_of_nano_precision(final String pattern) { assertPatternPrecision(pattern, ChronoUnit.NANOS); @@ -233,20 +202,31 @@ public class InstantPatternDynamicFormatterTest { assertPatternPrecision(pattern, ChronoUnit.SECONDS); } - @ParameterizedTest - @ValueSource( - strings = { + static Stream<String> should_recognize_patterns_of_minute_precision() { + Stream<String> stream = Stream.of( // Basics "m", "mm", + "Z", + "x", + "X", + "O", + "z", + "VV", // Mixed with other stuff "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm", "HH:mm", + "yyyy-MM-dd HH x", + "yyyy-MM-dd'T'HH XX", // Single-quoted text containing nanosecond and millisecond directives "yyyy-MM-dd'S'HH:mm", - "yyyy-MM-dd'n'HH:mm" - }) + "yyyy-MM-dd'n'HH:mm"); + return Constants.JAVA_MAJOR_VERSION > 8 ? Stream.concat(stream, Stream.of("v")) : stream; + } + + @ParameterizedTest + @MethodSource void should_recognize_patterns_of_minute_precision(final String pattern) { assertPatternPrecision(pattern, ChronoUnit.MINUTES); } @@ -267,28 +247,71 @@ public class InstantPatternDynamicFormatterTest { "K", "k", "H", - "Z", - "x", - "X", - "O", - "z", - "VV", // Mixed with other stuff "yyyy-MM-dd HH", "yyyy-MM-dd'T'HH", - "yyyy-MM-dd HH x", - "yyyy-MM-dd'T'HH XX", "ddHH", // Single-quoted text containing nanosecond and millisecond directives "yyyy-MM-dd'S'HH", "yyyy-MM-dd'n'HH")); if (Constants.JAVA_MAJOR_VERSION > 8) { java8Patterns.add("B"); - java8Patterns.add("v"); } return java8Patterns; } + static Stream<Arguments> dynamic_pattern_should_correctly_determine_precision() { + // When no a precise unit is not available, uses the closest smaller unit. + return Stream.of( + Arguments.of("G", ChronoUnit.ERAS), + Arguments.of("u", ChronoUnit.YEARS), + Arguments.of("D", ChronoUnit.DAYS), + Arguments.of("M", ChronoUnit.MONTHS), + Arguments.of("L", ChronoUnit.MONTHS), + Arguments.of("d", ChronoUnit.DAYS), + Arguments.of("Q", ChronoUnit.MONTHS), + Arguments.of("q", ChronoUnit.MONTHS), + Arguments.of("Y", ChronoUnit.YEARS), + Arguments.of("w", ChronoUnit.WEEKS), + Arguments.of("W", ChronoUnit.DAYS), // The month can change in the middle of the week + Arguments.of("F", ChronoUnit.DAYS), // The month can change in the middle of the week + Arguments.of("E", ChronoUnit.DAYS), + Arguments.of("e", ChronoUnit.DAYS), + Arguments.of("c", ChronoUnit.DAYS), + Arguments.of("a", ChronoUnit.HOURS), // Let us round it down + Arguments.of("h", ChronoUnit.HOURS), + Arguments.of("K", ChronoUnit.HOURS), + Arguments.of("k", ChronoUnit.HOURS), + Arguments.of("H", ChronoUnit.HOURS), + Arguments.of("m", ChronoUnit.MINUTES), + Arguments.of("s", ChronoUnit.SECONDS), + Arguments.of("S", ChronoUnit.MILLIS), + Arguments.of("SS", ChronoUnit.MILLIS), + Arguments.of("SSS", ChronoUnit.MILLIS), + Arguments.of("SSSS", ChronoUnit.MICROS), + Arguments.of("SSSSS", ChronoUnit.MICROS), + Arguments.of("SSSSSS", ChronoUnit.MICROS), + Arguments.of("SSSSSSS", ChronoUnit.NANOS), + Arguments.of("SSSSSSSS", ChronoUnit.NANOS), + Arguments.of("SSSSSSSSS", ChronoUnit.NANOS), + Arguments.of("A", ChronoUnit.MILLIS), + Arguments.of("n", ChronoUnit.NANOS), + Arguments.of("N", ChronoUnit.NANOS), + // Time zones can change in the middle of a UTC hour (e.g. India) + Arguments.of("VV", ChronoUnit.MINUTES), + Arguments.of("z", ChronoUnit.MINUTES), + Arguments.of("O", ChronoUnit.MINUTES), + Arguments.of("X", ChronoUnit.MINUTES), + Arguments.of("x", ChronoUnit.MINUTES), + Arguments.of("Z", ChronoUnit.MINUTES)); + } + + @ParameterizedTest + @MethodSource + void dynamic_pattern_should_correctly_determine_precision(String singlePattern, ChronoUnit expectedPrecision) { + assertThat(pDyn(singlePattern).precision).isEqualTo(expectedPrecision); + } + private static void assertPatternPrecision(final String pattern, final ChronoUnit expectedPrecision) { final InstantPatternFormatter formatter = new InstantPatternDynamicFormatter(pattern, Locale.getDefault(), TimeZone.getDefault()); @@ -351,7 +374,11 @@ public class InstantPatternDynamicFormatterTest { private static MutableInstant randomInstant() { final MutableInstant instant = new MutableInstant(); - final long epochSecond = RANDOM.nextInt(1_621_280_470); // 2021-05-17 21:41:10 + // In the 1970's some time zones had sub-minute offsets to UTC, e.g., Africa/Monrovia. + // We will exclude them for tests: + final int minEpochSecond = 315_532_800; // 1980-01-01 01:00:00 + final int maxEpochSecond = 1_621_280_470; // 2021-05-17 21:41:10 + final long epochSecond = minEpochSecond + RANDOM.nextInt(maxEpochSecond - minEpochSecond); final int epochSecondNano = randomNanos(); instant.initFromEpochSecond(epochSecond, epochSecondNano); return instant; diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatterTest.java index b25fb85d74..1f94b0b9ae 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatterTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatterTest.java @@ -107,7 +107,7 @@ class InstantPatternThreadLocalCachedFormatterTest { } @ParameterizedTest - @ValueSource(strings = {"S", "SSSS", "SSSSS", "SSSSSS", "SSSSSSS", "SSSSSSSS", "SSSSSSSSS", "n", "N"}) + @ValueSource(strings = {"SSSS", "SSSSS", "SSSSSS", "SSSSSSS", "SSSSSSSS", "SSSSSSSSS", "n", "N"}) void ofMilliPrecision_should_fail_on_inconsistent_precision(final String subMilliPattern) { final InstantPatternDynamicFormatter dynamicFormatter = new InstantPatternDynamicFormatter(subMilliPattern, LOCALE, TIME_ZONE); diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatter.java index a5486f7a50..d6fb4ccd2a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatter.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternDynamicFormatter.java @@ -29,10 +29,11 @@ import java.util.Locale; import java.util.Objects; import java.util.TimeZone; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Supplier; -import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.logging.log4j.core.time.Instant; import org.apache.logging.log4j.core.time.MutableInstant; +import org.apache.logging.log4j.util.BiConsumer; +import org.apache.logging.log4j.util.Strings; import org.jspecify.annotations.Nullable; /** @@ -47,30 +48,6 @@ import org.jspecify.annotations.Nullable; * <li>Precompute and cache the output for parts that are of precision lower than or equal to {@value InstantPatternDynamicFormatter#PRECISION_THRESHOLD} (i.e., {@code yyyy-MM-dd'T'HH:mm:} and {@code X}) and cache it</li> * <li>Upon a formatting request, combine the cached outputs with the dynamic parts (i.e., {@code ss.SSS})</li> * </ol> - * <h2>Implementation note</h2> - * <p> - * Formatting can actually even be made faster and garbage-free by manually formatting sub-minute precision directives as follows: - * </p> - * <pre>{@code - * int offsetMillis = timeZone.getOffset(mutableInstant.getEpochMillisecond()); - * long adjustedEpochSeconds = (instant.getEpochMillisecond() + offsetMillis) / 1000; - * int local_s = (int) (adjustedEpochSeconds % 60); - * int local_S = instant.getNanoOfSecond() / 100000000; - * int local_SS = instant.getNanoOfSecond() / 10000000; - * int local_SSS = instant.getNanoOfSecond() / 1000000; - * int local_SSSS = instant.getNanoOfSecond() / 100000; - * int local_SSSSS = instant.getNanoOfSecond() / 10000; - * int local_SSSSSS = instant.getNanoOfSecond() / 1000; - * int local_SSSSSSS = instant.getNanoOfSecond() / 100; - * int local_SSSSSSSS = instant.getNanoOfSecond() / 10; - * int local_SSSSSSSSS = instant.getNanoOfSecond(); - * int local_n = instant.getNanoOfSecond(); - * }</pre> - * <p> - * Though this will require more hardcoded formatting and a change in the sequence merging strategies. - * Hence, this optimization is intentionally shelved off due to involved complexity. - * See {@code verify_manually_computed_sub_minute_precision_values()} in {@code InstantPatternDynamicFormatterTest} for a demonstration of this optimization. - * </p> * * @since 2.25.0 */ @@ -165,7 +142,7 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { // Sequence the pattern and create associated formatters final List<PatternSequence> sequences = sequencePattern(pattern, precisionThreshold); - final List<InstantPatternFormatter> formatters = sequences.stream() + final InstantPatternFormatter[] formatters = sequences.stream() .map(sequence -> { final InstantPatternFormatter formatter = sequence.createFormatter(locale, timeZone); final boolean constant = sequence.isConstantForDurationOf(precisionThreshold); @@ -185,9 +162,9 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { } }; }) - .collect(Collectors.toList()); + .toArray(InstantPatternFormatter[]::new); - switch (formatters.size()) { + switch (formatters.length) { // If found an empty pattern, return an empty formatter case 0: @@ -200,17 +177,33 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { // If extracted a single formatter, return it as is case 1: - return formatters.get(0); + return formatters[0]; + + // Profiling shows that unrolling the generic loop boosts performance + case 2: + final InstantPatternFormatter first = formatters[0]; + final InstantPatternFormatter second = formatters[1]; + return new AbstractFormatter( + pattern, locale, timeZone, min(first.getPrecision(), second.getPrecision())) { + @Override + public void formatTo(StringBuilder buffer, Instant instant) { + first.formatTo(buffer, instant); + second.formatTo(buffer, instant); + } + }; // Combine all extracted formatters into one default: - final ChronoUnit precision = new CompositePatternSequence(sequences).precision; + final ChronoUnit precision = Stream.of(formatters) + .map(InstantFormatter::getPrecision) + .min(Comparator.comparing(ChronoUnit::getDuration)) + .get(); return new AbstractFormatter(pattern, locale, timeZone, precision) { @Override public void formatTo(final StringBuilder buffer, final Instant instant) { // noinspection ForLoopReplaceableByForEach (avoid iterator allocation) - for (int formatterIndex = 0; formatterIndex < formatters.size(); formatterIndex++) { - final InstantPatternFormatter formatter = formatters.get(formatterIndex); + for (int formatterIndex = 0; formatterIndex < formatters.length; formatterIndex++) { + final InstantPatternFormatter formatter = formatters[formatterIndex]; formatter.formatTo(buffer, instant); } } @@ -218,10 +211,13 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { } } + private static ChronoUnit min(ChronoUnit left, ChronoUnit right) { + return left.getDuration().compareTo(right.getDuration()) < 0 ? left : right; + } + static List<PatternSequence> sequencePattern(final String pattern, final ChronoUnit precisionThreshold) { List<PatternSequence> sequences = sequencePattern(pattern); - final List<PatternSequence> mergedSequences = mergeDynamicSequences(sequences, precisionThreshold); - return mergeConsequentEffectivelyConstantSequences(mergedSequences, precisionThreshold); + return mergeFactories(sequences, precisionThreshold); } private static List<PatternSequence> sequencePattern(final String pattern) { @@ -240,7 +236,17 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { endIndex++; } final String sequenceContent = pattern.substring(startIndex, endIndex); - final PatternSequence sequence = new DynamicPatternSequence(sequenceContent); + final PatternSequence sequence; + switch (c) { + case 's': + sequence = new SecondPatternSequence(true, "", 0); + break; + case 'S': + sequence = new SecondPatternSequence(false, "", sequenceContent.length()); + break; + default: + sequence = new DynamicPatternSequence(sequenceContent); + } sequences.add(sequence); startIndex = endIndex; } @@ -248,15 +254,7 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { // Handle single-quotes else if (c == '\'') { final int endIndex = pattern.indexOf('\'', startIndex + 1); - if (endIndex < 0) { - final String message = String.format( - "pattern ends with an incomplete string literal that started at index %d: `%s`", - startIndex, pattern); - throw new IllegalArgumentException(message); - } - final String sequenceLiteral = - (startIndex + 1) == endIndex ? "'" : pattern.substring(startIndex + 1, endIndex); - final PatternSequence sequence = new StaticPatternSequence(sequenceLiteral); + final PatternSequence sequence = getStaticPatternSequence(pattern, startIndex, endIndex); sequences.add(sequence); startIndex = endIndex + 1; } @@ -268,243 +266,53 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { startIndex++; } } - return mergeConsequentStaticPatternSequences(sequences); - } - - private static boolean isDynamicPatternLetter(final char c) { - return "GuyDMLdgQqYwWEecFaBhKkHmsSAnNVvzOXxZ".indexOf(c) >= 0; + return sequences; } - /** - * Merges consequent static sequences. - * - * <p> - * For example, the sequencing of the {@code [MM-dd] HH:mm} pattern will create two static sequences for {@code ]} (right brace) and {@code } (whitespace) characters. - * This method will combine such consequent static sequences into one. - * </p> - * - * <h2>Example</h2> - * - * <p> - * The {@code [MM-dd] HH:mm} pattern will result in following sequences: - * </p> - * - * <pre>{@code - * [ - * static(literal="["), - * dynamic(pattern="MM", precision=MONTHS), - * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), - * static(literal="]"), - * static(literal=" "), - * dynamic(pattern="HH", precision=HOURS), - * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES) - * ] - * }</pre> - * - * <p> - * The above sequencing implies creation of 9 {@link AbstractFormatter}s. - * This method transforms it to the following: - * </p> - * - * <pre>{@code - * [ - * static(literal="["), - * dynamic(pattern="MM", precision=MONTHS), - * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), - * static(literal="] "), - * dynamic(pattern="HH", precision=HOURS), - * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES) - * ] - * }</pre> - * - * <p> - * The above sequencing implies creation of 8 {@link AbstractFormatter}s. - * </p> - * - * @param sequences sequences to be transformed - * @return transformed sequencing where consequent static sequences are merged - */ - private static List<PatternSequence> mergeConsequentStaticPatternSequences(final List<PatternSequence> sequences) { - - // Short-circuit if there is nothing to merge - if (sequences.size() < 2) { - return sequences; - } - - final List<PatternSequence> mergedSequences = new ArrayList<>(); - final List<StaticPatternSequence> accumulatedSequences = new ArrayList<>(); - for (final PatternSequence sequence : sequences) { - - // Spotted a static sequence? Stage it for merging. - if (sequence instanceof StaticPatternSequence) { - accumulatedSequences.add((StaticPatternSequence) sequence); - } - - // Spotted a dynamic sequence. - // Merge the accumulated static sequences, and then append the dynamic sequence. - else { - mergeConsequentStaticPatternSequences(mergedSequences, accumulatedSequences); - mergedSequences.add(sequence); - } + private static PatternSequence getStaticPatternSequence(String pattern, int startIndex, int endIndex) { + if (endIndex < 0) { + final String message = String.format( + "pattern ends with an incomplete string literal that started at index %d: `%s`", + startIndex, pattern); + throw new IllegalArgumentException(message); } - - // Merge leftover static sequences - mergeConsequentStaticPatternSequences(mergedSequences, accumulatedSequences); - return mergedSequences; + final String sequenceLiteral = (startIndex + 1) == endIndex ? "'" : pattern.substring(startIndex + 1, endIndex); + return new StaticPatternSequence(sequenceLiteral); } - private static void mergeConsequentStaticPatternSequences( - final List<PatternSequence> mergedSequences, final List<StaticPatternSequence> accumulatedSequences) { - mergeAccumulatedSequences(mergedSequences, accumulatedSequences, () -> { - final String literal = accumulatedSequences.stream() - .map(sequence -> sequence.literal) - .collect(Collectors.joining()); - return new StaticPatternSequence(literal); - }); + private static boolean isDynamicPatternLetter(final char c) { + return "GuyDMLdgQqYwWEecFaBhKkHmsSAnNVvzOXxZ".indexOf(c) >= 0; } /** - * Merges the sequences in between the first and the last found dynamic (i.e., non-constant) sequences. - * - * <p> - * For example, given the {@code ss.SSS} pattern – where {@code ss} and {@code SSS} is effectively not constant, yet {@code .} is – this method will combine it into a single dynamic sequence. - * Because, as demonstrated in {@code DateTimeFormatterSequencingBenchmark}, formatting {@code ss.SSS} is approximately 20% faster than formatting first {@code ss}, then manually appending a {@code .}, and then formatting {@code SSS}. - * </p> + * Merges pattern sequences using {@link PatternSequence#tryMerge}. * * <h2>Example</h2> * * <p> - * Assume {@link #mergeConsequentStaticPatternSequences(List)} produced the following: + * For example, given the {@code yyyy-MM-dd'T'HH:mm:ss.SSS} pattern, a precision threshold of {@link ChronoUnit#MINUTES} + * and the three implementations ({@link DynamicPatternSequence}, {@link StaticPatternSequence} and + * {@link SecondPatternSequence}) from this class, + * this method will combine pattern sequences associated with {@code yyyy-MM-dd'T'HH:mm:} into a single sequence, + * since these are consecutive and effectively constant sequences. * </p> * * <pre>{@code * [ - * dynamic(pattern="yyyy", precision=YEARS), + * dateTimeFormatter(pattern="yyyy", precision=YEARS), * static(literal="-"), - * dynamic(pattern="MM", precision=MONTHS), + * dateTimeFormatter(pattern="MM", precision=MONTHS), * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), + * dateTimeFormatter(pattern="dd", precision=DAYS), * static(literal="T"), - * dynamic(pattern="HH", precision=HOURS), + * dateTimeFormatter(pattern="HH", precision=HOURS), * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES), + * dateTimeFormatter(pattern="mm", precision=MINUTES), * static(literal=":"), - * dynamic(pattern="ss", precision=SECONDS), + * second(pattern="ss", precision=SECONDS), * static(literal="."), - * dynamic(pattern="SSS", precision=MILLISECONDS), - * dynamic(pattern="X", precision=HOURS), - * ] - * }</pre> - * - * <p> - * For a threshold precision of {@link ChronoUnit#MINUTES}, this sequencing effectively translates to two {@link DateTimeFormatter#formatTo(TemporalAccessor, Appendable)} invocations for each {@link #formatTo(StringBuilder, Instant)} call: one for {@code ss}, and another one for {@code SSS}. - * This method transforms the above sequencing into the following: - * </p> - * - * <pre>{@code - * [ - * dynamic(pattern="yyyy", precision=YEARS), - * static(literal="-"), - * dynamic(pattern="MM", precision=MONTHS), - * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), - * static(literal="T"), - * dynamic(pattern="HH", precision=HOURS), - * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES), - * static(literal=":"), - * composite( - * sequences=[ - * dynamic(pattern="ss", precision=SECONDS), - * static(literal="."), - * dynamic(pattern="SSS", precision=MILLISECONDS) - * ], - * precision=MILLISECONDS), - * dynamic(pattern="X", precision=HOURS), - * ] - * }</pre> - * - * <p> - * The resultant sequencing effectively translates to a single {@link DateTimeFormatter#formatTo(TemporalAccessor, Appendable)} invocation for each {@link #formatTo(StringBuilder, Instant)} call: only one fore {@code ss.SSS}. - * </p> - * - * @param sequences sequences, preferable produced by {@link #mergeConsequentStaticPatternSequences(List)}, to be transformed - * @param precisionThreshold a precision threshold to determine dynamic (i.e., non-constant) sequences - * @return transformed sequencing where sequences in between the first and the last found dynamic (i.e., non-constant) sequences are merged - */ - private static List<PatternSequence> mergeDynamicSequences( - final List<PatternSequence> sequences, final ChronoUnit precisionThreshold) { - - // Locate the first and the last dynamic (i.e., non-constant) sequence indices - int firstDynamicSequenceIndex = -1; - int lastDynamicSequenceIndex = -1; - for (int sequenceIndex = 0; sequenceIndex < sequences.size(); sequenceIndex++) { - final PatternSequence sequence = sequences.get(sequenceIndex); - final boolean constant = sequence.isConstantForDurationOf(precisionThreshold); - if (!constant) { - if (firstDynamicSequenceIndex < 0) { - firstDynamicSequenceIndex = sequenceIndex; - } - lastDynamicSequenceIndex = sequenceIndex; - } - } - - // Short-circuit if there are less than 2 dynamic sequences - if (firstDynamicSequenceIndex < 0 || firstDynamicSequenceIndex == lastDynamicSequenceIndex) { - return sequences; - } - - // Merge dynamic sequences - final List<PatternSequence> mergedSequences = new ArrayList<>(); - if (firstDynamicSequenceIndex > 0) { - mergedSequences.addAll(sequences.subList(0, firstDynamicSequenceIndex)); - } - final PatternSequence mergedDynamicSequence = new CompositePatternSequence( - sequences.subList(firstDynamicSequenceIndex, lastDynamicSequenceIndex + 1)); - mergedSequences.add(mergedDynamicSequence); - if ((lastDynamicSequenceIndex + 1) < sequences.size()) { - mergedSequences.addAll(sequences.subList(lastDynamicSequenceIndex + 1, sequences.size())); - } - return mergedSequences; - } - - /** - * Merges sequences that are consequent and effectively constant for the provided precision threshold. - * - * <p> - * For example, given the {@code yyyy-MM-dd'T'HH:mm:ss.SSS} pattern and a precision threshold of {@link ChronoUnit#MINUTES}, this method will combine sequences associated with {@code yyyy-MM-dd'T'HH:mm:} into a single sequence, since these are consequent and effectively constant sequences. - * </p> - * - * <h2>Example</h2> - * - * <p> - * Assume {@link #mergeDynamicSequences(List, ChronoUnit)} produced the following: - * </p> - * - * <pre>{@code - * [ - * dynamic(pattern="yyyy", precision=YEARS), - * static(literal="-"), - * dynamic(pattern="MM", precision=MONTHS), - * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), - * static(literal="T"), - * dynamic(pattern="HH", precision=HOURS), - * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES), - * static(literal=":"), - * composite( - * sequences=[ - * dynamic(pattern="ss", precision=SECONDS), - * static(literal="."), - * dynamic(pattern="SSS", precision=MILLISECONDS) - * ], - * precision=MILLISECONDS), - * dynamic(pattern="X", precision=HOURS), + * second(pattern="SSS", precision=MILLISECONDS) + * dateTimeFormatter(pattern="X", precision=HOURS), * ] * }</pre> * @@ -515,28 +323,9 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { * * <pre>{@code * [ - * composite( - * sequences=[ - * dynamic(pattern="yyyy", precision=YEARS), - * static(literal="-"), - * dynamic(pattern="MM", precision=MONTHS), - * static(literal="-"), - * dynamic(pattern="dd", precision=DAYS), - * static(literal="T"), - * dynamic(pattern="HH", precision=HOURS), - * static(literal=":"), - * dynamic(pattern="mm", precision=MINUTES), - * static(literal=":") - * ], - * precision=MINUTES), - * composite( - * sequences=[ - * dynamic(pattern="ss", precision=SECONDS), - * static(literal="."), - * dynamic(pattern="SSS", precision=MILLISECONDS) - * ], - * precision=MILLISECONDS), - * dynamic(pattern="X", precision=HOURS), + * dateTimeFormatter(pattern="yyyy-MM-dd'T'HH:mm", precision=MINUTES), + * second(pattern="ss.SSS", precision=MILLISECONDS), + * dateTimeFormatter(pattern="X", precision=MINUTES) * ] * }</pre> * @@ -544,54 +333,32 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { * The resultant sequencing effectively translates to 3 {@link AbstractFormatter}s. * </p> * - * @param sequences sequences, preferable produced by {@link #mergeDynamicSequences(List, ChronoUnit)}, to be transformed + * @param sequences a list of pattern formatter factories * @param precisionThreshold a precision threshold to determine effectively constant sequences - * @return transformed sequencing where sequences that are consequent and effectively constant for the provided precision threshold are merged + * @return transformed sequencing, where sequences that are effectively constant or effectively dynamic are merged. */ - private static List<PatternSequence> mergeConsequentEffectivelyConstantSequences( + private static List<PatternSequence> mergeFactories( final List<PatternSequence> sequences, final ChronoUnit precisionThreshold) { - - // Short-circuit if there is nothing to merge if (sequences.size() < 2) { return sequences; } - final List<PatternSequence> mergedSequences = new ArrayList<>(); - boolean accumulatorConstant = true; - final List<PatternSequence> accumulatedSequences = new ArrayList<>(); - for (final PatternSequence sequence : sequences) { - final boolean sequenceConstant = sequence.isConstantForDurationOf(precisionThreshold); - if (sequenceConstant != accumulatorConstant) { - mergeConsequentEffectivelyConstantSequences(mergedSequences, accumulatedSequences); - accumulatorConstant = sequenceConstant; + PatternSequence currentFactory = sequences.get(0); + for (int i = 1; i < sequences.size(); i++) { + PatternSequence nextFactory = sequences.get(i); + PatternSequence mergedFactory = currentFactory.tryMerge(nextFactory, precisionThreshold); + // The current factory cannot be merged with the next one. + if (mergedFactory == null) { + mergedSequences.add(currentFactory); + currentFactory = nextFactory; + } else { + currentFactory = mergedFactory; } - accumulatedSequences.add(sequence); } - - // Merge the accumulator leftover - mergeConsequentEffectivelyConstantSequences(mergedSequences, accumulatedSequences); + mergedSequences.add(currentFactory); return mergedSequences; } - private static void mergeConsequentEffectivelyConstantSequences( - final List<PatternSequence> mergedSequences, final List<PatternSequence> accumulatedSequences) { - mergeAccumulatedSequences( - mergedSequences, accumulatedSequences, () -> new CompositePatternSequence(accumulatedSequences)); - } - - private static <S extends PatternSequence> void mergeAccumulatedSequences( - final List<PatternSequence> mergedSequences, - final List<S> accumulatedSequences, - final Supplier<PatternSequence> mergedSequenceSupplier) { - if (accumulatedSequences.isEmpty()) { - return; - } - final PatternSequence mergedSequence = - accumulatedSequences.size() == 1 ? accumulatedSequences.get(0) : mergedSequenceSupplier.get(); - mergedSequences.add(mergedSequence); - accumulatedSequences.clear(); - } - private static long toEpochMinutes(final Instant instant) { return instant.getEpochSecond() / 60; } @@ -612,7 +379,7 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { private final ChronoUnit precision; - private AbstractFormatter( + AbstractFormatter( final String pattern, final Locale locale, final TimeZone timeZone, final ChronoUnit precision) { this.pattern = pattern; this.locale = locale; @@ -654,22 +421,74 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { this.precision = precision; } - InstantPatternFormatter createFormatter(final Locale locale, final TimeZone timeZone) { - final DateTimeFormatter dateTimeFormatter = - DateTimeFormatter.ofPattern(pattern, locale).withZone(timeZone.toZoneId()); - return new AbstractFormatter(pattern, locale, timeZone, precision) { - @Override - public void formatTo(final StringBuilder buffer, final Instant instant) { - final TemporalAccessor instantAccessor = toTemporalAccessor(instant); - dateTimeFormatter.formatTo(instantAccessor, buffer); - } - }; + abstract InstantPatternFormatter createFormatter(final Locale locale, final TimeZone timeZone); + + /** + * Tries to merge two pattern sequences. + * + * <p> + * If not {@link null}, the pattern sequence returned by this method must: + * </p> + * <ol> + * <li>Have a {@link #precision}, which is the minimum of the precisions of the two merged sequences.</li> + * <li> + * Create formatters that are equivalent to the concatenation of the formatters produced by the + * two merged sequences. + * </li> + * </ol> + * <p> + * The returned pattern sequence should try to achieve these two goals: + * </p> + * <ol> + * <li> + * Create formatters which are faster than the concatenation of the formatters produced by the + * two merged sequences. + * </li> + * <li> + * It should be {@link null} if one of the pattern sequences is effectively constant over + * {@code thresholdPrecision}, but the other one is not. + * </li> + * </ol> + * + * @param other A pattern sequence. + * @param thresholdPrecision A precision threshold to determine effectively constant sequences. + * This prevents merging effectively constant and dynamic pattern sequences. + * @return A merged formatter factory or {@code null} if merging is not possible. + */ + @Nullable + PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) { + return null; } - private boolean isConstantForDurationOf(final ChronoUnit thresholdPrecision) { + boolean isConstantForDurationOf(final ChronoUnit thresholdPrecision) { return precision.compareTo(thresholdPrecision) >= 0; } + static String escapeLiteral(String literal) { + StringBuilder sb = new StringBuilder(literal.length() + 2); + boolean inSingleQuotes = false; + for (int i = 0; i < literal.length(); i++) { + char c = literal.charAt(i); + if (c == '\'') { + if (inSingleQuotes) { + sb.append("'"); + } + inSingleQuotes = false; + sb.append("''"); + } else { + if (!inSingleQuotes) { + sb.append("'"); + } + inSingleQuotes = true; + sb.append(c); + } + } + if (inSingleQuotes) { + sb.append("'"); + } + return sb.toString(); + } + @Override public boolean equals(final Object object) { if (this == object) { @@ -689,7 +508,7 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { @Override public String toString() { - return String.format("<%s>%s", pattern, precision); + return getClass().getSimpleName() + "[" + "pattern='" + pattern + '\'' + ", precision=" + precision + ']'; } } @@ -698,7 +517,7 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { private final String literal; StaticPatternSequence(final String literal) { - super(literal.equals("'") ? "''" : ("'" + literal + "'"), ChronoUnit.FOREVER); + super(escapeLiteral(literal), ChronoUnit.FOREVER); this.literal = literal; } @@ -711,44 +530,109 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { } }; } + + @Override + @Nullable + PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) { + // We always merge consecutive static pattern factories + if (other instanceof StaticPatternSequence) { + final StaticPatternSequence otherStatic = (StaticPatternSequence) other; + return new StaticPatternSequence(this.literal + otherStatic.literal); + } + // We also merge a static pattern factory with a DTF factory + if (other instanceof DynamicPatternSequence) { + final DynamicPatternSequence otherDtf = (DynamicPatternSequence) other; + return new DynamicPatternSequence(this.pattern + otherDtf.pattern, otherDtf.precision); + } + return null; + } } + /** + * Creates formatters that use {@link DateTimeFormatter}. + */ static final class DynamicPatternSequence extends PatternSequence { - DynamicPatternSequence(final String content) { - super(content, contentPrecision(content)); + /** + * @param singlePattern A {@link DateTimeFormatter} pattern containing a single letter. + */ + DynamicPatternSequence(final String singlePattern) { + this(singlePattern, patternPrecision(singlePattern)); } /** - * @param content a single-letter directive content complying (e.g., {@code H}, {@code HH}, or {@code pHH}) - * @return the time precision of the directive + * @param pattern Any {@link DateTimeFormatter} pattern. + * @param precision The maximum interval of time over which this pattern is constant. */ + DynamicPatternSequence(final String pattern, final ChronoUnit precision) { + super(pattern, precision); + } + + InstantPatternFormatter createFormatter(final Locale locale, final TimeZone timeZone) { + final DateTimeFormatter dateTimeFormatter = + DateTimeFormatter.ofPattern(pattern, locale).withZone(timeZone.toZoneId()); + return new AbstractFormatter(pattern, locale, timeZone, precision) { + @Override + public void formatTo(final StringBuilder buffer, final Instant instant) { + final TemporalAccessor instantAccessor = toTemporalAccessor(instant); + dateTimeFormatter.formatTo(instantAccessor, buffer); + } + }; + } + + @Override @Nullable - private static ChronoUnit contentPrecision(final String content) { + PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) { + // We merge two DTF factories if they are both above or below the threshold + if (other instanceof DynamicPatternSequence) { + final DynamicPatternSequence otherDtf = (DynamicPatternSequence) other; + if (isConstantForDurationOf(thresholdPrecision) + == otherDtf.isConstantForDurationOf(thresholdPrecision)) { + ChronoUnit precision = this.precision.getDuration().compareTo(otherDtf.precision.getDuration()) < 0 + ? this.precision + : otherDtf.precision; + return new DynamicPatternSequence(this.pattern + otherDtf.pattern, precision); + } + } + // We merge a static pattern factory + if (other instanceof StaticPatternSequence) { + final StaticPatternSequence otherStatic = (StaticPatternSequence) other; + return new DynamicPatternSequence(this.pattern + otherStatic.pattern, this.precision); + } + return null; + } - validateContent(content); - final String paddingRemovedContent = removePadding(content); + /** + * @param singlePattern a single-letter directive singlePattern complying (e.g., {@code H}, {@code HH}, or {@code pHH}) + * @return the time precision of the directive + */ + private static ChronoUnit patternPrecision(final String singlePattern) { - if (paddingRemovedContent.matches("[GuyY]+")) { + validateContent(singlePattern); + final String paddingRemovedContent = removePadding(singlePattern); + + if (paddingRemovedContent.matches("G+")) { + return ChronoUnit.ERAS; + } else if (paddingRemovedContent.matches("[uyY]+")) { return ChronoUnit.YEARS; } else if (paddingRemovedContent.matches("[MLQq]+")) { return ChronoUnit.MONTHS; - } else if (paddingRemovedContent.matches("[wW]+")) { + } else if (paddingRemovedContent.matches("w+")) { return ChronoUnit.WEEKS; - } else if (paddingRemovedContent.matches("[DdgEecF]+")) { + } else if (paddingRemovedContent.matches("[DdgEecFW]+")) { return ChronoUnit.DAYS; - } else if (paddingRemovedContent.matches("[aBhKkH]+") - // Time-zone directives - || paddingRemovedContent.matches("[ZxXOzvV]+")) { + } else if (paddingRemovedContent.matches("[aBhKkH]+")) { return ChronoUnit.HOURS; - } else if (paddingRemovedContent.contains("m")) { + } else if (paddingRemovedContent.contains("m") + // Time-zone directives + || paddingRemovedContent.matches("[ZxXOzVv]+")) { return ChronoUnit.MINUTES; } else if (paddingRemovedContent.contains("s")) { return ChronoUnit.SECONDS; } // 2 to 3 consequent `S` characters output millisecond precision - else if (paddingRemovedContent.matches("S{2,3}") + else if (paddingRemovedContent.matches("S{1,3}") // `A` (milli-of-day) outputs millisecond precision. || paddingRemovedContent.contains("A")) { return ChronoUnit.MILLIS; @@ -759,17 +643,15 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { return ChronoUnit.MICROS; } - // A single `S` (fraction-of-second) outputs nanosecond precision - else if (paddingRemovedContent.equals("S") - // 7 to 9 consequent `S` characters output nanosecond precision - || paddingRemovedContent.matches("S{7,9}") + // 7 to 9 consequent `S` characters output nanosecond precision + else if (paddingRemovedContent.matches("S{7,9}") // `n` (nano-of-second) and `N` (nano-of-day) always output nanosecond precision. // This is independent of how many times they occur sequentially. || paddingRemovedContent.matches("[nN]+")) { return ChronoUnit.NANOS; } - final String message = String.format("unrecognized pattern: `%s`", content); + final String message = String.format("unrecognized pattern: `%s`", singlePattern); throw new IllegalArgumentException(message); } @@ -806,26 +688,125 @@ final class InstantPatternDynamicFormatter implements InstantPatternFormatter { } } - static final class CompositePatternSequence extends PatternSequence { + static class SecondPatternSequence extends PatternSequence { + + private static final int[] POWERS_OF_TEN = { + 100_000_000, 10_000_000, 1_000_000, 100_000, 10_000, 1_000, 100, 10, 1 + }; + + private final boolean printSeconds; + private final String separator; + private final int fractionalDigits; + + SecondPatternSequence(boolean printSeconds, String separator, int fractionalDigits) { + super( + createPattern(printSeconds, separator, fractionalDigits), + determinePrecision(printSeconds, fractionalDigits)); + this.printSeconds = printSeconds; + this.separator = separator; + this.fractionalDigits = fractionalDigits; + } + + private static String createPattern(boolean printSeconds, String separator, int fractionalDigits) { + StringBuilder builder = new StringBuilder(); + if (printSeconds) { + builder.append("ss"); + } + builder.append(StaticPatternSequence.escapeLiteral(separator)); + if (fractionalDigits > 0) { + builder.append(Strings.repeat("S", fractionalDigits)); + } + return builder.toString(); + } + + private static ChronoUnit determinePrecision(boolean printSeconds, int digits) { + if (digits > 6) return ChronoUnit.NANOS; + if (digits > 3) return ChronoUnit.MICROS; + if (digits > 0) return ChronoUnit.MILLIS; + return printSeconds ? ChronoUnit.SECONDS : ChronoUnit.FOREVER; + } + + private static void formatSeconds(StringBuilder buffer, Instant instant) { + long secondsInMinute = instant.getEpochSecond() % 60L; + buffer.append((char) ((secondsInMinute / 10L) + '0')); + buffer.append((char) ((secondsInMinute % 10L) + '0')); + } - CompositePatternSequence(final List<PatternSequence> sequences) { - super(concatSequencePatterns(sequences), findSequenceMaxPrecision(sequences)); - // Only allow two or more sequences - if (sequences.size() < 2) { - throw new IllegalArgumentException("was expecting two or more sequences: " + sequences); + private void formatFractionalDigits(StringBuilder buffer, Instant instant) { + int nanos = instant.getNanoOfSecond(); + // digits contain the first idx digits. + int digits; + // moreDigits contains the first (idx + 1) digits + int moreDigits = 0; + // Print the digits + for (int idx = 0; idx < fractionalDigits; idx++) { + digits = moreDigits; + moreDigits = nanos / POWERS_OF_TEN[idx]; + buffer.append((char) ('0' + moreDigits - 10 * digits)); } } - @SuppressWarnings("OptionalGetWithoutIsPresent") - private static ChronoUnit findSequenceMaxPrecision(List<PatternSequence> sequences) { - return sequences.stream() - .map(sequence -> sequence.precision) - .min(Comparator.comparing(ChronoUnit::getDuration)) - .get(); + private static void formatMillis(StringBuilder buffer, Instant instant) { + int ms = instant.getNanoOfSecond() / 1_000_000; + int cs = ms / 10; + int ds = cs / 10; + buffer.append((char) ('0' + ds)); + buffer.append((char) ('0' + cs - 10 * ds)); + buffer.append((char) ('0' + ms - 10 * cs)); } - private static String concatSequencePatterns(List<PatternSequence> sequences) { - return sequences.stream().map(sequence -> sequence.pattern).collect(Collectors.joining()); + @Override + InstantPatternFormatter createFormatter(Locale locale, TimeZone timeZone) { + final BiConsumer<StringBuilder, Instant> fractionDigitsFormatter = + fractionalDigits == 3 ? SecondPatternSequence::formatMillis : this::formatFractionalDigits; + if (!printSeconds) { + return new AbstractFormatter(pattern, locale, timeZone, precision) { + @Override + public void formatTo(StringBuilder buffer, Instant instant) { + buffer.append(separator); + fractionDigitsFormatter.accept(buffer, instant); + } + }; + } + if (fractionalDigits == 0) { + return new AbstractFormatter(pattern, locale, timeZone, precision) { + @Override + public void formatTo(StringBuilder buffer, Instant instant) { + formatSeconds(buffer, instant); + buffer.append(separator); + } + }; + } + return new AbstractFormatter(pattern, locale, timeZone, precision) { + @Override + public void formatTo(StringBuilder buffer, Instant instant) { + formatSeconds(buffer, instant); + buffer.append(separator); + fractionDigitsFormatter.accept(buffer, instant); + } + }; + } + + @Override + @Nullable + PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) { + // If we don't have a fractional part, we can merge a literal separator + if (other instanceof StaticPatternSequence) { + StaticPatternSequence staticOther = (StaticPatternSequence) other; + if (fractionalDigits == 0) { + return new SecondPatternSequence( + printSeconds, this.separator + staticOther.literal, fractionalDigits); + } + } + // We can always append more fractional digits + if (other instanceof SecondPatternSequence) { + SecondPatternSequence secondOther = (SecondPatternSequence) other; + if (!secondOther.printSeconds && secondOther.separator.isEmpty()) { + return new SecondPatternSequence( + printSeconds, this.separator, this.fractionalDigits + secondOther.fractionalDigits); + } + } + return null; } } } diff --git a/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc b/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc index 6f20199718..ff79bfad9c 100644 --- a/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/json-template-layout.adoc @@ -1263,6 +1263,13 @@ unit = "unit" -> ( rounded = "rounded" -> boolean ---- +[NOTE] +==== +The resolvers based on the `epochConfig` expression are garbage-free. + +The resolvers based on the `patternConfig` expression are low-garbage and generate temporary objects only once a minute. +==== + .See examples [%collapsible] ==== diff --git a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc index a1e01e809d..ee9115a2ed 100644 --- a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc @@ -1571,7 +1571,9 @@ Format modifiers to control such things as field width, padding, left, and right |xref:#converter-date[%d + %date] -|Only the predefined date formats (`DEFAULT`, `ISO8601`, `UNIX`, `UNIX_MILLIS`, etc.) are garbage-free +| +The numeric formats (`UNIX` and `UNIX_MILLIS`) are garbage-free. +The remaining formats are low-garbage and only generate temporary objects once per minute. |xref:#converter-encode[%enc\{pattern} + %encode\{pattern}]