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.  *      https://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.      * The empty string.
  75.      */
  76.     private static final String EMPTY_PATTERN = StringUtils.EMPTY;

  77.     /**
  78.      * A comma.
  79.      */
  80.     private static final char START_FMT = ',';

  81.     /**
  82.      * A right curly bracket.
  83.      */
  84.     private static final char END_FE = '}';

  85.     /**
  86.      * A left curly bracket.
  87.      */
  88.     private static final char START_FE = '{';

  89.     /**
  90.      * A properly escaped character representing a single quote.
  91.      */
  92.     private static final char QUOTE = '\'';

  93.     /**
  94.      * To pattern string.
  95.      */
  96.     private String toPattern;

  97.     /**
  98.      * Our registry of FormatFactory.
  99.      */
  100.     private final Map<String, ? extends FormatFactory> registry;

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

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

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

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

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

  161.         // handle quote character at the beginning of the string
  162.         if (appendTo != null) {
  163.             appendTo.append(QUOTE);
  164.         }
  165.         next(pos);

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

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

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

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

  268.     @Override
  269.     public boolean equals(final Object obj) {
  270.         if (this == obj) {
  271.             return true;
  272.         }
  273.         if (!super.equals(obj)) {
  274.             return false;
  275.         }
  276.         if (!(obj instanceof ExtendedMessageFormat)) {
  277.             return false;
  278.         }
  279.         final ExtendedMessageFormat other = (ExtendedMessageFormat) obj;
  280.         return Objects.equals(registry, other.registry) && Objects.equals(toPattern, other.toPattern);
  281.     }

  282.     /**
  283.      * Gets a custom format from a format description.
  284.      *
  285.      * @param desc String
  286.      * @return Format
  287.      */
  288.     private Format getFormat(final String desc) {
  289.         if (registry != null) {
  290.             String name = desc;
  291.             String args = null;
  292.             final int i = desc.indexOf(START_FMT);
  293.             if (i > 0) {
  294.                 name = desc.substring(0, i).trim();
  295.                 args = desc.substring(i + 1).trim();
  296.             }
  297.             final FormatFactory factory = registry.get(name);
  298.             if (factory != null) {
  299.                 return factory.getFormat(name, args, getLocale());
  300.             }
  301.         }
  302.         return null;
  303.     }

  304.     /**
  305.      * Consumes quoted string only.
  306.      *
  307.      * @param pattern pattern to parse
  308.      * @param pos current parse position
  309.      */
  310.     private void getQuotedString(final String pattern, final ParsePosition pos) {
  311.         appendQuotedString(pattern, pos, null);
  312.     }

  313.     @Override
  314.     public int hashCode() {
  315.         final int prime = 31;
  316.         final int result = super.hashCode();
  317.         return prime * result + Objects.hash(registry, toPattern);
  318.     }

  319.     /**
  320.      * Inserts formats back into the pattern for toPattern() support.
  321.      *
  322.      * @param pattern source
  323.      * @param customPatterns The custom patterns to re-insert, if any
  324.      * @return full pattern
  325.      */
  326.     private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
  327.         if (!containsElements(customPatterns)) {
  328.             return pattern;
  329.         }
  330.         final StringBuilder sb = new StringBuilder(pattern.length() * 2);
  331.         final ParsePosition pos = new ParsePosition(0);
  332.         int fe = -1;
  333.         int depth = 0;
  334.         while (pos.getIndex() < pattern.length()) {
  335.             final char c = pattern.charAt(pos.getIndex());
  336.             switch (c) {
  337.             case QUOTE:
  338.                 appendQuotedString(pattern, pos, sb);
  339.                 break;
  340.             case START_FE:
  341.                 depth++;
  342.                 sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
  343.                 // do not look for custom patterns when they are embedded, e.g. in a choice
  344.                 if (depth == 1) {
  345.                     fe++;
  346.                     final String customPattern = customPatterns.get(fe);
  347.                     if (customPattern != null) {
  348.                         sb.append(START_FMT).append(customPattern);
  349.                     }
  350.                 }
  351.                 break;
  352.             case END_FE:
  353.                 depth--;
  354.                 //$FALL-THROUGH$
  355.             default:
  356.                 sb.append(c);
  357.                 next(pos);
  358.             }
  359.         }
  360.         return sb.toString();
  361.     }

  362.     /**
  363.      * Advances parse position by 1.
  364.      *
  365.      * @param pos ParsePosition
  366.      * @return {@code pos}
  367.      */
  368.     private ParsePosition next(final ParsePosition pos) {
  369.         pos.setIndex(pos.getIndex() + 1);
  370.         return pos;
  371.     }

  372.     /**
  373.      * Parses the format component of a format element.
  374.      *
  375.      * @param pattern string to parse
  376.      * @param pos current parse position
  377.      * @return Format description String
  378.      */
  379.     private String parseFormatDescription(final String pattern, final ParsePosition pos) {
  380.         final int start = pos.getIndex();
  381.         seekNonWs(pattern, pos);
  382.         final int text = pos.getIndex();
  383.         int depth = 1;
  384.         while (pos.getIndex() < pattern.length()) {
  385.             switch (pattern.charAt(pos.getIndex())) {
  386.             case START_FE:
  387.                 depth++;
  388.                 next(pos);
  389.                 break;
  390.             case END_FE:
  391.                 depth--;
  392.                 if (depth == 0) {
  393.                     return pattern.substring(text, pos.getIndex());
  394.                 }
  395.                 next(pos);
  396.                 break;
  397.             case QUOTE:
  398.                 getQuotedString(pattern, pos);
  399.                 break;
  400.             default:
  401.                 next(pos);
  402.                 break;
  403.             }
  404.         }
  405.         throw new IllegalArgumentException(
  406.                 "Unterminated format element at position " + start);
  407.     }

  408.     /**
  409.      * Reads the argument index from the current format element.
  410.      *
  411.      * @param pattern pattern to parse
  412.      * @param pos current parse position
  413.      * @return argument index
  414.      */
  415.     private int readArgumentIndex(final String pattern, final ParsePosition pos) {
  416.         final int start = pos.getIndex();
  417.         seekNonWs(pattern, pos);
  418.         final StringBuilder result = new StringBuilder();
  419.         boolean error = false;
  420.         for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
  421.             char c = pattern.charAt(pos.getIndex());
  422.             if (Character.isWhitespace(c)) {
  423.                 seekNonWs(pattern, pos);
  424.                 c = pattern.charAt(pos.getIndex());
  425.                 if (c != START_FMT && c != END_FE) {
  426.                     error = true;
  427.                     continue;
  428.                 }
  429.             }
  430.             if ((c == START_FMT || c == END_FE) && result.length() > 0) {
  431.                 try {
  432.                     return Integer.parseInt(result.toString());
  433.                 } catch (final NumberFormatException e) { // NOPMD
  434.                     // we've already ensured only digits, so unless something
  435.                     // outlandishly large was specified we should be okay.
  436.                 }
  437.             }
  438.             error = !Character.isDigit(c);
  439.             result.append(c);
  440.         }
  441.         if (error) {
  442.             throw new IllegalArgumentException(
  443.                     "Invalid format argument index at position " + start + ": "
  444.                             + pattern.substring(start, pos.getIndex()));
  445.         }
  446.         throw new IllegalArgumentException(
  447.                 "Unterminated format element at position " + start);
  448.     }

  449.     /**
  450.      * Consumes whitespace from the current parse position.
  451.      *
  452.      * @param pattern String to read
  453.      * @param pos current position
  454.      */
  455.     private void seekNonWs(final String pattern, final ParsePosition pos) {
  456.         int len = 0;
  457.         final char[] buffer = pattern.toCharArray();
  458.         do {
  459.             len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
  460.             pos.setIndex(pos.getIndex() + len);
  461.         } while (len > 0 && pos.getIndex() < pattern.length());
  462.     }

  463.     /**
  464.      * Throws UnsupportedOperationException - see class Javadoc for details.
  465.      *
  466.      * @param formatElementIndex format element index
  467.      * @param newFormat the new format
  468.      * @throws UnsupportedOperationException always thrown since this isn't
  469.      *                                       supported by ExtendMessageFormat
  470.      */
  471.     @Override
  472.     public void setFormat(final int formatElementIndex, final Format newFormat) {
  473.         throw new UnsupportedOperationException();
  474.     }

  475.     /**
  476.      * Throws UnsupportedOperationException - see class Javadoc for details.
  477.      *
  478.      * @param argumentIndex argument index
  479.      * @param newFormat the new format
  480.      * @throws UnsupportedOperationException always thrown since this isn't
  481.      *                                       supported by ExtendMessageFormat
  482.      */
  483.     @Override
  484.     public void setFormatByArgumentIndex(final int argumentIndex,
  485.                                          final Format newFormat) {
  486.         throw new UnsupportedOperationException();
  487.     }

  488.     /**
  489.      * Throws UnsupportedOperationException - see class Javadoc for details.
  490.      *
  491.      * @param newFormats new formats
  492.      * @throws UnsupportedOperationException always thrown since this isn't
  493.      *                                       supported by ExtendMessageFormat
  494.      */
  495.     @Override
  496.     public void setFormats(final Format[] newFormats) {
  497.         throw new UnsupportedOperationException();
  498.     }

  499.     /**
  500.      * Throws UnsupportedOperationException - see class Javadoc for details.
  501.      *
  502.      * @param newFormats new formats
  503.      * @throws UnsupportedOperationException always thrown since this isn't
  504.      *                                       supported by ExtendMessageFormat
  505.      */
  506.     @Override
  507.     public void setFormatsByArgumentIndex(final Format[] newFormats) {
  508.         throw new UnsupportedOperationException();
  509.     }

  510.     /**
  511.      * {@inheritDoc}
  512.      */
  513.     @Override
  514.     public String toPattern() {
  515.         return toPattern;
  516.     }
  517. }