ExtendedMessageFormat.java

  1. /*
  2.  * Licensed to the Apache Software Foundation (ASF) under one or more
  3.  * contributor license agreements.  See the NOTICE file distributed with
  4.  * this work for additional information regarding copyright ownership.
  5.  * The ASF licenses this file to You under the Apache License, Version 2.0
  6.  * (the "License"); you may not use this file except in compliance with
  7.  * the License.  You may obtain a copy of the License at
  8.  *
  9.  *      http://www.apache.org/licenses/LICENSE-2.0
  10.  *
  11.  * Unless required by applicable law or agreed to in writing, software
  12.  * distributed under the License is distributed on an "AS IS" BASIS,
  13.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14.  * See the License for the specific language governing permissions and
  15.  * limitations under the License.
  16.  */
  17. package org.apache.commons.text;

  18. import java.text.Format;
  19. import java.text.MessageFormat;
  20. import java.text.ParsePosition;
  21. import java.util.ArrayList;
  22. import java.util.Collection;
  23. import java.util.Collections;
  24. import java.util.HashMap;
  25. import java.util.Locale;
  26. import java.util.Locale.Category;
  27. import java.util.Map;
  28. import java.util.Objects;

  29. import org.apache.commons.lang3.StringUtils;
  30. import org.apache.commons.text.matcher.StringMatcherFactory;

  31. /**
  32.  * Extends {@link java.text.MessageFormat} to allow pluggable/additional formatting
  33.  * options for embedded format elements.  Client code should specify a registry
  34.  * of {@code FormatFactory} instances associated with {@code String}
  35.  * format names.  This registry will be consulted when the format elements are
  36.  * parsed from the message pattern.  In this way custom patterns can be specified,
  37.  * and the formats supported by {@link java.text.MessageFormat} can be overridden
  38.  * at the format and/or format style level (see MessageFormat).  A "format element"
  39.  * embedded in the message pattern is specified (<strong>()?</strong> signifies optionality):<br>
  40.  * {@code {}<em>argument-number</em><strong>(</strong>{@code ,}<em>format-name</em><b>
  41.  * (</b>{@code ,}<em>format-style</em><strong>)?)?</strong>{@code }}
  42.  *
  43.  * <p>
  44.  * <em>format-name</em> and <em>format-style</em> values are trimmed of surrounding whitespace
  45.  * in the manner of {@link java.text.MessageFormat}.  If <em>format-name</em> denotes
  46.  * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@code Format}
  47.  * matching <em>format-name</em> and <em>format-style</em> is requested from
  48.  * {@code formatFactoryInstance}.  If this is successful, the {@code Format}
  49.  * found is used for this format element.
  50.  * </p>
  51.  *
  52.  * <p><strong>NOTICE:</strong> The various subformat mutator methods are considered unnecessary; they exist on the parent
  53.  * class to allow the type of customization which it is the job of this class to provide in
  54.  * a configurable fashion.  These methods have thus been disabled and will throw
  55.  * {@code UnsupportedOperationException} if called.
  56.  * </p>
  57.  *
  58.  * <p>Limitations inherited from {@link java.text.MessageFormat}:</p>
  59.  * <ul>
  60.  * <li>When using "choice" subformats, support for nested formatting instructions is limited
  61.  *     to that provided by the base class.</li>
  62.  * <li>Thread-safety of {@code Format}s, including {@code MessageFormat} and thus
  63.  *     {@code ExtendedMessageFormat}, is not guaranteed.</li>
  64.  * </ul>
  65.  *
  66.  * @since 1.0
  67.  */
  68. public class ExtendedMessageFormat extends MessageFormat {

  69.     /**
  70.      * Serializable Object.
  71.      */
  72.     private static final long serialVersionUID = -2362048321261811743L;

  73.     /**
  74.      * Our initial seed value for calculating hashes.
  75.      */
  76.     private static final int HASH_SEED = 31;

  77.     /**
  78.      * The empty string.
  79.      */
  80.     private static final String DUMMY_PATTERN = StringUtils.EMPTY;

  81.     /**
  82.      * A comma.
  83.      */
  84.     private static final char START_FMT = ',';

  85.     /**
  86.      * A right side squiggly brace.
  87.      */
  88.     private static final char END_FE = '}';

  89.     /**
  90.      * A left side squiggly brace.
  91.      */
  92.     private static final char START_FE = '{';

  93.     /**
  94.      * A properly escaped character representing a single quote.
  95.      */
  96.     private static final char QUOTE = '\'';

  97.     /**
  98.      * To pattern string.
  99.      */
  100.     private String toPattern;

  101.     /**
  102.      * Our registry of FormatFactory.
  103.      */
  104.     private final Map<String, ? extends FormatFactory> registry;

  105.     /**
  106.      * Constructs a new ExtendedMessageFormat for the default locale.
  107.      *
  108.      * @param pattern  the pattern to use, not null
  109.      * @throws IllegalArgumentException in case of a bad pattern.
  110.      */
  111.     public ExtendedMessageFormat(final String pattern) {
  112.         this(pattern, Locale.getDefault(Category.FORMAT));
  113.     }

  114.     /**
  115.      * Constructs a new ExtendedMessageFormat.
  116.      *
  117.      * @param pattern  the pattern to use, not null
  118.      * @param locale  the locale to use, not null
  119.      * @throws IllegalArgumentException in case of a bad pattern.
  120.      */
  121.     public ExtendedMessageFormat(final String pattern, final Locale locale) {
  122.         this(pattern, locale, null);
  123.     }

  124.     /**
  125.      * Constructs a new ExtendedMessageFormat.
  126.      *
  127.      * @param pattern  the pattern to use, not null
  128.      * @param locale  the locale to use, not null
  129.      * @param registry  the registry of format factories, may be null
  130.      * @throws IllegalArgumentException in case of a bad pattern.
  131.      */
  132.     public ExtendedMessageFormat(final String pattern,
  133.                                  final Locale locale,
  134.                                  final Map<String, ? extends FormatFactory> registry) {
  135.         super(DUMMY_PATTERN);
  136.         setLocale(locale);
  137.         this.registry = registry != null
  138.                 ? Collections.unmodifiableMap(new HashMap<>(registry))
  139.                 : null;
  140.         applyPattern(pattern);
  141.     }

  142.     /**
  143.      * Constructs a new ExtendedMessageFormat for the default locale.
  144.      *
  145.      * @param pattern  the pattern to use, not null
  146.      * @param registry  the registry of format factories, may be null
  147.      * @throws IllegalArgumentException in case of a bad pattern.
  148.      */
  149.     public ExtendedMessageFormat(final String pattern,
  150.                                  final Map<String, ? extends FormatFactory> registry) {
  151.         this(pattern, Locale.getDefault(Category.FORMAT), registry);
  152.     }

  153.     /**
  154.      * Consumes a quoted string, adding it to {@code appendTo} if
  155.      * specified.
  156.      *
  157.      * @param pattern pattern to parse
  158.      * @param pos current parse position
  159.      * @param appendTo optional StringBuilder to append
  160.      */
  161.     private void appendQuotedString(final String pattern, final ParsePosition pos,
  162.             final StringBuilder appendTo) {
  163.         assert pattern.toCharArray()[pos.getIndex()] == QUOTE
  164.                 : "Quoted string must start with quote character";

  165.         // handle quote character at the beginning of the string
  166.         if (appendTo != null) {
  167.             appendTo.append(QUOTE);
  168.         }
  169.         next(pos);

  170.         final int start = pos.getIndex();
  171.         final char[] c = pattern.toCharArray();
  172.         for (int i = pos.getIndex(); i < pattern.length(); i++) {
  173.             switch (c[pos.getIndex()]) {
  174.             case QUOTE:
  175.                 next(pos);
  176.                 if (appendTo != null) {
  177.                     appendTo.append(c, start, pos.getIndex() - start);
  178.                 }
  179.                 return;
  180.             default:
  181.                 next(pos);
  182.             }
  183.         }
  184.         throw new IllegalArgumentException(
  185.                 "Unterminated quoted string at position " + start);
  186.     }

  187.     /**
  188.      * Applies the specified pattern.
  189.      *
  190.      * @param pattern String
  191.      */
  192.     @Override
  193.     public final void applyPattern(final String pattern) {
  194.         if (registry == null) {
  195.             super.applyPattern(pattern);
  196.             toPattern = super.toPattern();
  197.             return;
  198.         }
  199.         final ArrayList<Format> foundFormats = new ArrayList<>();
  200.         final ArrayList<String> foundDescriptions = new ArrayList<>();
  201.         final StringBuilder stripCustom = new StringBuilder(pattern.length());

  202.         final ParsePosition pos = new ParsePosition(0);
  203.         final char[] c = pattern.toCharArray();
  204.         int fmtCount = 0;
  205.         while (pos.getIndex() < pattern.length()) {
  206.             switch (c[pos.getIndex()]) {
  207.             case QUOTE:
  208.                 appendQuotedString(pattern, pos, stripCustom);
  209.                 break;
  210.             case START_FE:
  211.                 fmtCount++;
  212.                 seekNonWs(pattern, pos);
  213.                 final int start = pos.getIndex();
  214.                 final int index = readArgumentIndex(pattern, next(pos));
  215.                 stripCustom.append(START_FE).append(index);
  216.                 seekNonWs(pattern, pos);
  217.                 Format format = null;
  218.                 String formatDescription = null;
  219.                 if (c[pos.getIndex()] == START_FMT) {
  220.                     formatDescription = parseFormatDescription(pattern,
  221.                             next(pos));
  222.                     format = getFormat(formatDescription);
  223.                     if (format == null) {
  224.                         stripCustom.append(START_FMT).append(formatDescription);
  225.                     }
  226.                 }
  227.                 foundFormats.add(format);
  228.                 foundDescriptions.add(format == null ? null : formatDescription);
  229.                 if (foundFormats.size() != fmtCount) {
  230.                     throw new IllegalArgumentException("The validated expression is false");
  231.                 }
  232.                 if (foundDescriptions.size() != fmtCount) {
  233.                     throw new IllegalArgumentException("The validated expression is false");
  234.                 }
  235.                 if (c[pos.getIndex()] != END_FE) {
  236.                     throw new IllegalArgumentException(
  237.                             "Unreadable format element at position " + start);
  238.                 }
  239.                 //$FALL-THROUGH$
  240.             default:
  241.                 stripCustom.append(c[pos.getIndex()]);
  242.                 next(pos);
  243.             }
  244.         }
  245.         super.applyPattern(stripCustom.toString());
  246.         toPattern = insertFormats(super.toPattern(), foundDescriptions);
  247.         if (containsElements(foundFormats)) {
  248.             final Format[] origFormats = getFormats();
  249.             // only loop over what we know we have, as MessageFormat on Java 1.3
  250.             // seems to provide an extra format element:
  251.             int i = 0;
  252.             for (final Format f : foundFormats) {
  253.                 if (f != null) {
  254.                     origFormats[i] = f;
  255.                 }
  256.                 i++;
  257.             }
  258.             super.setFormats(origFormats);
  259.         }
  260.     }

  261.     /**
  262.      * Tests whether the specified Collection contains non-null elements.
  263.      * @param coll to check
  264.      * @return {@code true} if some Object was found, {@code false} otherwise.
  265.      */
  266.     private boolean containsElements(final Collection<?> coll) {
  267.         if (coll == null || coll.isEmpty()) {
  268.             return false;
  269.         }
  270.         return coll.stream().anyMatch(Objects::nonNull);
  271.     }

  272.     /**
  273.      * Tests if this extended message format is equal to another object.
  274.      *
  275.      * @param obj the object to compare to
  276.      * @return true if this object equals the other, otherwise false
  277.      */
  278.     @Override
  279.     public boolean equals(final Object obj) {
  280.         if (obj == this) {
  281.             return true;
  282.         }
  283.         if (obj == null) {
  284.             return false;
  285.         }
  286.         if (!Objects.equals(getClass(), obj.getClass())) {
  287.           return false;
  288.         }
  289.         final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
  290.         if (!Objects.equals(toPattern, rhs.toPattern)) {
  291.             return false;
  292.         }
  293.         if (!super.equals(obj)) {
  294.             return false;
  295.         }
  296.         return Objects.equals(registry, rhs.registry);
  297.     }

  298.     /**
  299.      * Gets a custom format from a format description.
  300.      *
  301.      * @param desc String
  302.      * @return Format
  303.      */
  304.     private Format getFormat(final String desc) {
  305.         if (registry != null) {
  306.             String name = desc;
  307.             String args = null;
  308.             final int i = desc.indexOf(START_FMT);
  309.             if (i > 0) {
  310.                 name = desc.substring(0, i).trim();
  311.                 args = desc.substring(i + 1).trim();
  312.             }
  313.             final FormatFactory factory = registry.get(name);
  314.             if (factory != null) {
  315.                 return factory.getFormat(name, args, getLocale());
  316.             }
  317.         }
  318.         return null;
  319.     }

  320.     /**
  321.      * Consumes quoted string only.
  322.      *
  323.      * @param pattern pattern to parse
  324.      * @param pos current parse position
  325.      */
  326.     private void getQuotedString(final String pattern, final ParsePosition pos) {
  327.         appendQuotedString(pattern, pos, null);
  328.     }

  329.     /**
  330.      * {@inheritDoc}
  331.      */
  332.     @Override
  333.     public int hashCode() {
  334.         int result = super.hashCode();
  335.         result = HASH_SEED * result + Objects.hashCode(registry);
  336.         return HASH_SEED * result + Objects.hashCode(toPattern);
  337.     }

  338.     /**
  339.      * Inserts formats back into the pattern for toPattern() support.
  340.      *
  341.      * @param pattern source
  342.      * @param customPatterns The custom patterns to re-insert, if any
  343.      * @return full pattern
  344.      */
  345.     private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
  346.         if (!containsElements(customPatterns)) {
  347.             return pattern;
  348.         }
  349.         final StringBuilder sb = new StringBuilder(pattern.length() * 2);
  350.         final ParsePosition pos = new ParsePosition(0);
  351.         int fe = -1;
  352.         int depth = 0;
  353.         while (pos.getIndex() < pattern.length()) {
  354.             final char c = pattern.charAt(pos.getIndex());
  355.             switch (c) {
  356.             case QUOTE:
  357.                 appendQuotedString(pattern, pos, sb);
  358.                 break;
  359.             case START_FE:
  360.                 depth++;
  361.                 sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
  362.                 // do not look for custom patterns when they are embedded, e.g. in a choice
  363.                 if (depth == 1) {
  364.                     fe++;
  365.                     final String customPattern = customPatterns.get(fe);
  366.                     if (customPattern != null) {
  367.                         sb.append(START_FMT).append(customPattern);
  368.                     }
  369.                 }
  370.                 break;
  371.             case END_FE:
  372.                 depth--;
  373.                 //$FALL-THROUGH$
  374.             default:
  375.                 sb.append(c);
  376.                 next(pos);
  377.             }
  378.         }
  379.         return sb.toString();
  380.     }

  381.     /**
  382.      * Advances parse position by 1.
  383.      *
  384.      * @param pos ParsePosition
  385.      * @return {@code pos}
  386.      */
  387.     private ParsePosition next(final ParsePosition pos) {
  388.         pos.setIndex(pos.getIndex() + 1);
  389.         return pos;
  390.     }

  391.     /**
  392.      * Parses the format component of a format element.
  393.      *
  394.      * @param pattern string to parse
  395.      * @param pos current parse position
  396.      * @return Format description String
  397.      */
  398.     private String parseFormatDescription(final String pattern, final ParsePosition pos) {
  399.         final int start = pos.getIndex();
  400.         seekNonWs(pattern, pos);
  401.         final int text = pos.getIndex();
  402.         int depth = 1;
  403.         while (pos.getIndex() < pattern.length()) {
  404.             switch (pattern.charAt(pos.getIndex())) {
  405.             case START_FE:
  406.                 depth++;
  407.                 next(pos);
  408.                 break;
  409.             case END_FE:
  410.                 depth--;
  411.                 if (depth == 0) {
  412.                     return pattern.substring(text, pos.getIndex());
  413.                 }
  414.                 next(pos);
  415.                 break;
  416.             case QUOTE:
  417.                 getQuotedString(pattern, pos);
  418.                 break;
  419.             default:
  420.                 next(pos);
  421.                 break;
  422.             }
  423.         }
  424.         throw new IllegalArgumentException(
  425.                 "Unterminated format element at position " + start);
  426.     }

  427.     /**
  428.      * Reads the argument index from the current format element.
  429.      *
  430.      * @param pattern pattern to parse
  431.      * @param pos current parse position
  432.      * @return argument index
  433.      */
  434.     private int readArgumentIndex(final String pattern, final ParsePosition pos) {
  435.         final int start = pos.getIndex();
  436.         seekNonWs(pattern, pos);
  437.         final StringBuilder result = new StringBuilder();
  438.         boolean error = false;
  439.         for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
  440.             char c = pattern.charAt(pos.getIndex());
  441.             if (Character.isWhitespace(c)) {
  442.                 seekNonWs(pattern, pos);
  443.                 c = pattern.charAt(pos.getIndex());
  444.                 if (c != START_FMT && c != END_FE) {
  445.                     error = true;
  446.                     continue;
  447.                 }
  448.             }
  449.             if ((c == START_FMT || c == END_FE) && result.length() > 0) {
  450.                 try {
  451.                     return Integer.parseInt(result.toString());
  452.                 } catch (final NumberFormatException e) { // NOPMD
  453.                     // we've already ensured only digits, so unless something
  454.                     // outlandishly large was specified we should be okay.
  455.                 }
  456.             }
  457.             error = !Character.isDigit(c);
  458.             result.append(c);
  459.         }
  460.         if (error) {
  461.             throw new IllegalArgumentException(
  462.                     "Invalid format argument index at position " + start + ": "
  463.                             + pattern.substring(start, pos.getIndex()));
  464.         }
  465.         throw new IllegalArgumentException(
  466.                 "Unterminated format element at position " + start);
  467.     }

  468.     /**
  469.      * Consumes whitespace from the current parse position.
  470.      *
  471.      * @param pattern String to read
  472.      * @param pos current position
  473.      */
  474.     private void seekNonWs(final String pattern, final ParsePosition pos) {
  475.         int len = 0;
  476.         final char[] buffer = pattern.toCharArray();
  477.         do {
  478.             len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
  479.             pos.setIndex(pos.getIndex() + len);
  480.         } while (len > 0 && pos.getIndex() < pattern.length());
  481.     }

  482.     /**
  483.      * Throws UnsupportedOperationException - see class Javadoc for details.
  484.      *
  485.      * @param formatElementIndex format element index
  486.      * @param newFormat the new format
  487.      * @throws UnsupportedOperationException always thrown since this isn't
  488.      *                                       supported by ExtendMessageFormat
  489.      */
  490.     @Override
  491.     public void setFormat(final int formatElementIndex, final Format newFormat) {
  492.         throw new UnsupportedOperationException();
  493.     }

  494.     /**
  495.      * Throws UnsupportedOperationException - see class Javadoc for details.
  496.      *
  497.      * @param argumentIndex argument index
  498.      * @param newFormat the new format
  499.      * @throws UnsupportedOperationException always thrown since this isn't
  500.      *                                       supported by ExtendMessageFormat
  501.      */
  502.     @Override
  503.     public void setFormatByArgumentIndex(final int argumentIndex,
  504.                                          final Format newFormat) {
  505.         throw new UnsupportedOperationException();
  506.     }

  507.     /**
  508.      * Throws UnsupportedOperationException - see class Javadoc for details.
  509.      *
  510.      * @param newFormats new formats
  511.      * @throws UnsupportedOperationException always thrown since this isn't
  512.      *                                       supported by ExtendMessageFormat
  513.      */
  514.     @Override
  515.     public void setFormats(final Format[] newFormats) {
  516.         throw new UnsupportedOperationException();
  517.     }

  518.     /**
  519.      * Throws UnsupportedOperationException - see class Javadoc for details.
  520.      *
  521.      * @param newFormats new formats
  522.      * @throws UnsupportedOperationException always thrown since this isn't
  523.      *                                       supported by ExtendMessageFormat
  524.      */
  525.     @Override
  526.     public void setFormatsByArgumentIndex(final Format[] newFormats) {
  527.         throw new UnsupportedOperationException();
  528.     }

  529.     /**
  530.      * {@inheritDoc}
  531.      */
  532.     @Override
  533.     public String toPattern() {
  534.         return toPattern;
  535.     }
  536. }