@@ -253,7 +253,23 @@ private static List<PatternSequence> sequencePattern(final String pattern) {
253253
254254 // Handle single-quotes
255255 else if (c == '\'' ) {
256- final int endIndex = pattern .indexOf ('\'' , startIndex + 1 );
256+ int endIndex = startIndex + 1 ;
257+ while (endIndex < pattern .length ()) {
258+ if (pattern .charAt (endIndex ) == '\'' ) {
259+ if ((endIndex + 1 ) < pattern .length () && pattern .charAt (endIndex + 1 ) == '\'' ) {
260+ // Escaped apostrophe, skip it
261+ endIndex += 2 ;
262+ } else {
263+ // Closing quote found
264+ break ;
265+ }
266+ } else {
267+ endIndex ++;
268+ }
269+ }
270+ if (endIndex >= pattern .length ()) {
271+ endIndex = -1 ; // Signal incomplete literal
272+ }
257273 final PatternSequence sequence = getStaticPatternSequence (pattern , startIndex , endIndex );
258274 sequences .add (sequence );
259275 startIndex = endIndex + 1 ;
@@ -276,7 +292,10 @@ private static PatternSequence getStaticPatternSequence(String pattern, int star
276292 startIndex , pattern );
277293 throw new IllegalArgumentException (message );
278294 }
279- final String sequenceLiteral = (startIndex + 1 ) == endIndex ? "'" : pattern .substring (startIndex + 1 , endIndex );
295+ // Extract the literal, replacing escaped apostrophes with a single apostrophe
296+ final String sequenceLiteral = (startIndex + 1 ) == endIndex
297+ ? "'"
298+ : pattern .substring (startIndex + 1 , endIndex ).replace ("''" , "'" );
280299 return new StaticPatternSequence (sequenceLiteral );
281300 }
282301
@@ -414,8 +433,32 @@ abstract static class PatternSequence {
414433
415434 final ChronoUnit precision ;
416435
436+ /**
437+ * Creates a {@code PatternSequence} from a {@link java.time.format.DateTimeFormatter DateTimeFormatter} pattern
438+ * and its precision.
439+ *
440+ * <p><strong>Quoting invariant:</strong> every literal in {@code pattern} must be enclosed in single quotes.
441+ * To include a lone apostrophe as a literal, use {@code "''''"} (open quote, escaped apostrophe {@code ''}, close quote).
442+ * Never use a bare {@code "''"}: while syntactically valid, it becomes ambiguous at concatenation boundaries.
443+ * This contract lets us merge adjacent quoted blocks in a purely context-free way
444+ * (drop the left closing quote and the right opening quote).</p>
445+ *
446+ * <p><b>Examples</b>:
447+ * <pre>{@code
448+ * "yyyy-MM-dd 'at' HH:mm" // OK: 'at' is a quoted literal
449+ * "HH 'o''clock'" // OK: apostrophe inside a quoted block is escaped as ''
450+ * "yyyy''''MM" // OK: emits a literal apostrophe between year and month
451+ * }</pre>
452+ *
453+ * @param pattern a DateTimeFormatter pattern with all literals fully quoted
454+ * @param precision the largest {@link java.time.temporal.ChronoUnit ChronoUnit} interval over which the
455+ * formatted output remains constant for this pattern
456+ * @throws NullPointerException if {@code pattern} or {@code precision} is {@code null}
457+ * @throws IllegalArgumentException if {@code pattern} is not a valid {@code DateTimeFormatter} pattern
458+ */
417459 @ SuppressWarnings ("ReturnValueIgnored" )
418460 PatternSequence (final String pattern , final ChronoUnit precision ) {
461+ assert !"''" .equals (pattern );
419462 DateTimeFormatter .ofPattern (pattern ); // Validate the pattern
420463 this .pattern = pattern ;
421464 this .precision = precision ;
@@ -456,37 +499,40 @@ abstract static class PatternSequence {
456499 * @return A merged formatter factory or {@code null} if merging is not possible.
457500 */
458501 @ Nullable
459- PatternSequence tryMerge (PatternSequence other , ChronoUnit thresholdPrecision ) {
460- return null ;
461- }
502+ abstract PatternSequence tryMerge (PatternSequence other , ChronoUnit thresholdPrecision );
462503
463504 boolean isConstantForDurationOf (final ChronoUnit thresholdPrecision ) {
464505 return precision .compareTo (thresholdPrecision ) >= 0 ;
465506 }
466507
467508 static String escapeLiteral (String literal ) {
468- StringBuilder sb = new StringBuilder (literal .length () + 2 );
469- boolean inSingleQuotes = false ;
470- for (int i = 0 ; i < literal .length (); i ++) {
471- char c = literal .charAt (i );
472- if (c == '\'' ) {
473- if (inSingleQuotes ) {
474- sb .append ("'" );
475- }
476- inSingleQuotes = false ;
477- sb .append ("''" );
478- } else {
479- if (!inSingleQuotes ) {
480- sb .append ("'" );
481- }
482- inSingleQuotes = true ;
483- sb .append (c );
484- }
485- }
486- if (inSingleQuotes ) {
487- sb .append ("'" );
509+ // Ensure that an empty literal is not quoted as "''",
510+ // which would be interpreted as an apostrophe-escape sequence.
511+ return literal .isEmpty () ? "" : "'" + literal .replace ("'" , "''" ) + "'" ;
512+ }
513+
514+ /**
515+ * Concatenates two DateTimeFormatter pattern fragments.
516+ * <p>
517+ * Precondition (enforced by the caller): every literal is fully quoted.
518+ * Even a lone apostrophe is emitted as the quoted literal block "''''"
519+ * (open quote, escaped apostrophe, and close quote).
520+ * We never use a bare "''".
521+ * </
522+ */
523+ static String mergePatterns (String left , String right ) {
524+ if (left .isEmpty ()) return right ;
525+ if (right .isEmpty ()) return left ;
526+
527+ if (left .charAt (left .length () - 1 ) == '\'' && right .charAt (0 ) == '\'' ) {
528+ // Stitch two adjacent quoted-literal blocks into one by removing the
529+ // boundary quotes (close-then-open).
530+ // Without this, concatenation would yield "...''..." at the join, which would change semantics.
531+ //
532+ // See: https://github.com/apache/logging-log4j2/issues/3930
533+ return left .substring (0 , left .length () - 1 ) + right .substring (1 );
488534 }
489- return sb . toString () ;
535+ return left + right ;
490536 }
491537
492538 @ Override
@@ -537,12 +583,12 @@ PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) {
537583 // We always merge consecutive static pattern factories
538584 if (other instanceof StaticPatternSequence ) {
539585 final StaticPatternSequence otherStatic = (StaticPatternSequence ) other ;
540- return new StaticPatternSequence (this .literal + otherStatic .literal );
586+ return new StaticPatternSequence (mergePatterns ( this .literal , otherStatic .literal ) );
541587 }
542588 // We also merge a static pattern factory with a DTF factory
543589 if (other instanceof DynamicPatternSequence ) {
544590 final DynamicPatternSequence otherDtf = (DynamicPatternSequence ) other ;
545- return new DynamicPatternSequence (this .pattern + otherDtf .pattern , otherDtf .precision );
591+ return new DynamicPatternSequence (mergePatterns ( this .pattern , otherDtf .pattern ) , otherDtf .precision );
546592 }
547593 return null ;
548594 }
@@ -591,13 +637,13 @@ PatternSequence tryMerge(PatternSequence other, ChronoUnit thresholdPrecision) {
591637 ChronoUnit precision = this .precision .getDuration ().compareTo (otherDtf .precision .getDuration ()) < 0
592638 ? this .precision
593639 : otherDtf .precision ;
594- return new DynamicPatternSequence (this .pattern + otherDtf .pattern , precision );
640+ return new DynamicPatternSequence (mergePatterns ( this .pattern , otherDtf .pattern ) , precision );
595641 }
596642 }
597643 // We merge a static pattern factory
598644 if (other instanceof StaticPatternSequence ) {
599645 final StaticPatternSequence otherStatic = (StaticPatternSequence ) other ;
600- return new DynamicPatternSequence (this .pattern + otherStatic .pattern , this .precision );
646+ return new DynamicPatternSequence (mergePatterns ( this .pattern , otherStatic .pattern ) , this .precision );
601647 }
602648 return null ;
603649 }
0 commit comments