diff --git a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java index 9e88bf1152e..a883732e9d6 100644 --- a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java +++ b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java @@ -44,9 +44,10 @@ * sseconds * Smilliseconds * 'text'arbitrary text content + * ''literal single quote (apostrophe) * * - * Note: It's not currently possible to include a single-quote in a format. + * A literal single quote is represented by a pair of consecutive single quotes ({@code ''}). *

* Token values are printed using decimal digits. * A token character can be repeated to ensure that the field occupies a certain minimum @@ -669,7 +670,6 @@ static Token[] lexx(final String format) { } String value = null; switch (ch) { - // TODO: Need to handle escaping of ' case '[': if (inOptional) { throw new IllegalArgumentException("Nested optional block at index: " + i); @@ -685,12 +685,27 @@ static Token[] lexx(final String format) { break; case '\'': if (inLiteral) { - buffer = null; - inLiteral = false; + if (i + 1 < format.length() && format.charAt(i + 1) == '\'') { + // escaped quote '' ? append literal apostrophe, stay in literal + buffer.append('\''); + i++; + } else { + // end of literal + buffer = null; + inLiteral = false; + } } else { - buffer = new StringBuilder(); - list.add(new Token(buffer, inOptional, optionalIndex)); - inLiteral = true; + if (i + 1 < format.length() && format.charAt(i + 1) == '\'') { + // standalone '' outside a literal ? emit a single apostrophe + buffer = new StringBuilder("'"); + list.add(new Token(buffer, inOptional, optionalIndex)); + buffer = null; + i++; + } else { + buffer = new StringBuilder(); + list.add(new Token(buffer, inOptional, optionalIndex)); + inLiteral = true; + } } break; case 'y': diff --git a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java index fdadfff9877..e9edbb2d73c 100644 --- a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java +++ b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java @@ -34,6 +34,9 @@ import org.apache.commons.lang3.AbstractLangTest; import org.apache.commons.lang3.time.DurationFormatUtils.Token; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.DefaultTimeZone; /** @@ -461,7 +464,7 @@ void testFormatPeriod() { cal.set(Calendar.MILLISECOND, 0); time = cal.getTime().getTime(); assertEquals("40", DurationFormatUtils.formatPeriod(time1970, time, "yM")); - assertEquals("4 years 0 months", DurationFormatUtils.formatPeriod(time1970, time, "y' ''years' M 'months'")); + assertEquals("4 'years 0 months", DurationFormatUtils.formatPeriod(time1970, time, "y' ''years' M 'months'")); assertEquals("4 years 0 months", DurationFormatUtils.formatPeriod(time1970, time, "y' years 'M' months'")); assertEquals("4years 0months", DurationFormatUtils.formatPeriod(time1970, time, "y'years 'M'months'")); assertEquals("04/00", DurationFormatUtils.formatPeriod(time1970, time, "yy/MM")); @@ -470,7 +473,7 @@ void testFormatPeriod() { assertEquals("048", DurationFormatUtils.formatPeriod(time1970, time, "MMM")); // no date in result assertEquals("hello", DurationFormatUtils.formatPeriod(time1970, time, "'hello'")); - assertEquals("helloworld", DurationFormatUtils.formatPeriod(time1970, time, "'hello''world'")); + assertEquals("hello'world", DurationFormatUtils.formatPeriod(time1970, time, "'hello''world'")); } @Test @@ -584,6 +587,34 @@ void testLANG815() { void testLANG981() { // unmatched quote char in lexx assertIllegalArgumentException(() -> DurationFormatUtils.lexx("'yMdHms''S")); } + + private static Arguments[] testLANG1827() { + final long twoHours = Duration.ofHours(2).toMillis(); + final long twoHoursThirtyMin = Duration.ofHours(2).plusMinutes(30).toMillis(); + final long oneDayTwoHours = Duration.ofDays(1).plusHours(2).toMillis(); + return new Arguments[] { + Arguments.of("escaped quote inside literal", "2 o'clock", twoHours, "H' o''clock'"), + Arguments.of("escaped quote at start of literal", "it's 2 hours", twoHours, "'it''s 'H' hours'"), + Arguments.of("escaped quote outside literal", "2'30", twoHoursThirtyMin, "H''m"), + Arguments.of("multiple escaped quotes", "it's been 1 day's and 2 hour's", oneDayTwoHours, + "'it''s been 'd' day''s and 'H' hour''s'"), + Arguments.of("standalone escaped quote", "2h'30m", twoHoursThirtyMin, "H'h'''m'm'"), + Arguments.of("escaped quote inside optional block", "2 hour's", twoHours, "[d' day''s ']H' hour''s'"), + Arguments.of("existing literal behavior", "2 hours 30 minutes", twoHoursThirtyMin, "H' hours 'm' minutes'") + }; + } + + @ParameterizedTest + @MethodSource + void testLANG1827(final String label, final String expected, final long durationMillis, final String format) { + assertEquals(expected, DurationFormatUtils.formatDuration(durationMillis, format), label); + } + + @Test + void testLANG1827UnmatchedQuote() { + assertIllegalArgumentException(() -> DurationFormatUtils.lexx("'unmatched")); + } + @Test void testLANG982() { // More than 3 millisecond digits following a second assertEquals("61.999", DurationFormatUtils.formatDuration(61999, "s.S"));