    001/*
    002 * Licensed to the Apache Software Foundation (ASF) under one or more
    003 * contributor license agreements.  See the NOTICE file distributed with
    004 * this work for additional information regarding copyright ownership.
    005 * The ASF licenses this file to You under the Apache License, Version 2.0
    006 * (the "License"); you may not use this file except in compliance with
    007 * the License.  You may obtain a copy of the License at
    008 *
    009 *      http://www.apache.org/licenses/LICENSE-2.0
    010 *
    011 * Unless required by applicable law or agreed to in writing, software
    012 * distributed under the License is distributed on an "AS IS" BASIS,
    013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    014 * See the License for the specific language governing permissions and
    015 * limitations under the License.
    016 */
    017package org.apache.commons.text;
    018
    019import java.text.Format;
    020import java.text.MessageFormat;
    021import java.text.ParsePosition;
    022import java.util.ArrayList;
    023import java.util.Collection;
    024import java.util.Iterator;
    025import java.util.Locale;
    026import java.util.Locale.Category;
    027import java.util.Map;
    028import java.util.Objects;
    029
    030import org.apache.commons.text.matcher.StringMatcherFactory;
    031
    032/**
    033 * Extends <code>java.text.MessageFormat</code> to allow pluggable/additional formatting
    034 * options for embedded format elements.  Client code should specify a registry
    035 * of <code>FormatFactory</code> instances associated with <code>String</code>
    036 * format names.  This registry will be consulted when the format elements are
    037 * parsed from the message pattern.  In this way custom patterns can be specified,
    038 * and the formats supported by <code>java.text.MessageFormat</code> can be overridden
    039 * at the format and/or format style level (see MessageFormat).  A "format element"
    040 * embedded in the message pattern is specified (<b>()?</b> signifies optionality):<br>
    041 * <code>{</code><i>argument-number</i><b>(</b><code>,</code><i>format-name</i><b>
    042 * (</b><code>,</code><i>format-style</i><b>)?)?</b><code>}</code>
    043 *
    044 * <p>
    045 * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace
    046 * in the manner of <code>java.text.MessageFormat</code>.  If <i>format-name</i> denotes
    047 * <code>FormatFactory formatFactoryInstance</code> in <code>registry</code>, a <code>Format</code>
    048 * matching <i>format-name</i> and <i>format-style</i> is requested from
    049 * <code>formatFactoryInstance</code>.  If this is successful, the <code>Format</code>
    050 * found is used for this format element.
    051 * </p>
    052 *
    053 * <p><b>NOTICE:</b> The various subformat mutator methods are considered unnecessary; they exist on the parent
    054 * class to allow the type of customization which it is the job of this class to provide in
    055 * a configurable fashion.  These methods have thus been disabled and will throw
    056 * <code>UnsupportedOperationException</code> if called.
    057 * </p>
    058 *
    059 * <p>Limitations inherited from <code>java.text.MessageFormat</code>:</p>
    060 * <ul>
    061 * <li>When using "choice" subformats, support for nested formatting instructions is limited
    062 *     to that provided by the base class.</li>
    063 * <li>Thread-safety of <code>Format</code>s, including <code>MessageFormat</code> and thus
    064 *     <code>ExtendedMessageFormat</code>, is not guaranteed.</li>
    065 * </ul>
    066 *
    067 * @since 1.0
    068 */
    069public class ExtendedMessageFormat extends MessageFormat {
    070
    071    /**
    072     * Serializable Object.
    073     */
    074    private static final long serialVersionUID = -2362048321261811743L;
    075
    076    /**
    077     * Our initial seed value for calculating hashes.
    078     */
    079    private static final int HASH_SEED = 31;
    080
    081    /**
    082     * The empty string.
    083     */
    084    private static final String DUMMY_PATTERN = "";
    085
    086    /**
    087     * A comma.
    088     */
    089    private static final char START_FMT = ',';
    090
    091    /**
    092     * A right side squigly brace.
    093     */
    094    private static final char END_FE = '}';
    095
    096    /**
    097     * A left side squigly brace.
    098     */
    099    private static final char START_FE = '{';
    100
    101    /**
    102     * A properly escaped character representing a single quote.
    103     */
    104    private static final char QUOTE = '\'';
    105
    106    /**
    107     * To pattern string.
    108     */
    109    private String toPattern;
    110
    111    /**
    112     * Our registry of FormatFactory's.
    113     */
    114    private final Map<String, ? extends FormatFactory> registry;
    115
    116    /**
    117     * Create a new ExtendedMessageFormat for the default locale.
    118     *
    119     * @param pattern  the pattern to use, not null
    120     * @throws IllegalArgumentException in case of a bad pattern.
    121     */
    122    public ExtendedMessageFormat(final String pattern) {
    123        this(pattern, Locale.getDefault(Category.FORMAT));
    124    }
    125
    126    /**
    127     * Create a new ExtendedMessageFormat.
    128     *
    129     * @param pattern  the pattern to use, not null
    130     * @param locale  the locale to use, not null
    131     * @throws IllegalArgumentException in case of a bad pattern.
    132     */
    133    public ExtendedMessageFormat(final String pattern, final Locale locale) {
    134        this(pattern, locale, null);
    135    }
    136
    137    /**
    138     * Create a new ExtendedMessageFormat for the default locale.
    139     *
    140     * @param pattern  the pattern to use, not null
    141     * @param registry  the registry of format factories, may be null
    142     * @throws IllegalArgumentException in case of a bad pattern.
    143     */
    144    public ExtendedMessageFormat(final String pattern,
    145                                 final Map<String, ? extends FormatFactory> registry) {
    146        this(pattern, Locale.getDefault(Category.FORMAT), registry);
    147    }
    148
    149    /**
    150     * Create a new ExtendedMessageFormat.
    151     *
    152     * @param pattern  the pattern to use, not null
    153     * @param locale  the locale to use, not null
    154     * @param registry  the registry of format factories, may be null
    155     * @throws IllegalArgumentException in case of a bad pattern.
    156     */
    157    public ExtendedMessageFormat(final String pattern,
    158                                 final Locale locale,
    159                                 final Map<String, ? extends FormatFactory> registry) {
    160        super(DUMMY_PATTERN);
    161        setLocale(locale);
    162        this.registry = registry;
    163        applyPattern(pattern);
    164    }
    165
    166    /**
    167     * {@inheritDoc}
    168     */
    169    @Override
    170    public String toPattern() {
    171        return toPattern;
    172    }
    173
    174    /**
    175     * Apply the specified pattern.
    176     *
    177     * @param pattern String
    178     */
    179    @Override
    180    public final void applyPattern(final String pattern) {
    181        if (registry == null) {
    182            super.applyPattern(pattern);
    183            toPattern = super.toPattern();
    184            return;
    185        }
    186        final ArrayList<Format> foundFormats = new ArrayList<>();
    187        final ArrayList<String> foundDescriptions = new ArrayList<>();
    188        final StringBuilder stripCustom = new StringBuilder(pattern.length());
    189
    190        final ParsePosition pos = new ParsePosition(0);
    191        final char[] c = pattern.toCharArray();
    192        int fmtCount = 0;
    193        while (pos.getIndex() < pattern.length()) {
    194            switch (c[pos.getIndex()]) {
    195            case QUOTE:
    196                appendQuotedString(pattern, pos, stripCustom);
    197                break;
    198            case START_FE:
    199                fmtCount++;
    200                seekNonWs(pattern, pos);
    201                final int start = pos.getIndex();
    202                final int index = readArgumentIndex(pattern, next(pos));
    203                stripCustom.append(START_FE).append(index);
    204                seekNonWs(pattern, pos);
    205                Format format = null;
    206                String formatDescription = null;
    207                if (c[pos.getIndex()] == START_FMT) {
    208                    formatDescription = parseFormatDescription(pattern,
    209                            next(pos));
    210                    format = getFormat(formatDescription);
    211                    if (format == null) {
    212                        stripCustom.append(START_FMT).append(formatDescription);
    213                    }
    214                }
    215                foundFormats.add(format);
    216                foundDescriptions.add(format == null ? null : formatDescription);
    217                if (foundFormats.size() != fmtCount) {
    218                    throw new IllegalArgumentException("The validated expression is false");
    219                }
    220                if (foundDescriptions.size() != fmtCount) {
    221                    throw new IllegalArgumentException("The validated expression is false");
    222                }
    223                if (c[pos.getIndex()] != END_FE) {
    224                    throw new IllegalArgumentException(
    225                            "Unreadable format element at position " + start);
    226                }
    227                //$FALL-THROUGH$
    228            default:
    229                stripCustom.append(c[pos.getIndex()]);
    230                next(pos);
    231            }
    232        }
    233        super.applyPattern(stripCustom.toString());
    234        toPattern = insertFormats(super.toPattern(), foundDescriptions);
    235        if (containsElements(foundFormats)) {
    236            final Format[] origFormats = getFormats();
    237            // only loop over what we know we have, as MessageFormat on Java 1.3
    238            // seems to provide an extra format element:
    239            int i = 0;
    240            for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
    241                final Format f = it.next();
    242                if (f != null) {
    243                    origFormats[i] = f;
    244                }
    245            }
    246            super.setFormats(origFormats);
    247        }
    248    }
    249
    250    /**
    251     * Throws UnsupportedOperationException - see class Javadoc for details.
    252     *
    253     * @param formatElementIndex format element index
    254     * @param newFormat the new format
    255     * @throws UnsupportedOperationException always thrown since this isn't
    256     *                                       supported by ExtendMessageFormat
    257     */
    258    @Override
    259    public void setFormat(final int formatElementIndex, final Format newFormat) {
    260        throw new UnsupportedOperationException();
    261    }
    262
    263    /**
    264     * Throws UnsupportedOperationException - see class Javadoc for details.
    265     *
    266     * @param argumentIndex argument index
    267     * @param newFormat the new format
    268     * @throws UnsupportedOperationException always thrown since this isn't
    269     *                                       supported by ExtendMessageFormat
    270     */
    271    @Override
    272    public void setFormatByArgumentIndex(final int argumentIndex,
    273                                         final Format newFormat) {
    274        throw new UnsupportedOperationException();
    275    }
    276
    277    /**
    278     * Throws UnsupportedOperationException - see class Javadoc for details.
    279     *
    280     * @param newFormats new formats
    281     * @throws UnsupportedOperationException always thrown since this isn't
    282     *                                       supported by ExtendMessageFormat
    283     */
    284    @Override
    285    public void setFormats(final Format[] newFormats) {
    286        throw new UnsupportedOperationException();
    287    }
    288
    289    /**
    290     * Throws UnsupportedOperationException - see class Javadoc for details.
    291     *
    292     * @param newFormats new formats
    293     * @throws UnsupportedOperationException always thrown since this isn't
    294     *                                       supported by ExtendMessageFormat
    295     */
    296    @Override
    297    public void setFormatsByArgumentIndex(final Format[] newFormats) {
    298        throw new UnsupportedOperationException();
    299    }
    300
    301    /**
    302     * Check if this extended message format is equal to another object.
    303     *
    304     * @param obj the object to compare to
    305     * @return true if this object equals the other, otherwise false
    306     */
    307    @Override
    308    public boolean equals(final Object obj) {
    309        if (obj == this) {
    310            return true;
    311        }
    312        if (obj == null) {
    313            return false;
    314        }
    315        if (!Objects.equals(getClass(), obj.getClass())) {
    316          return false;
    317        }
    318        final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
    319        if (!Objects.equals(toPattern, rhs.toPattern)) {
    320            return false;
    321        }
    322        if (!super.equals(obj)) {
    323            return false;
    324        }
    325        return Objects.equals(registry, rhs.registry);
    326    }
    327
    328    /**
    329     * {@inheritDoc}
    330     */
    331    @Override
    332    public int hashCode() {
    333        int result = super.hashCode();
    334        result = HASH_SEED * result + Objects.hashCode(registry);
    335        result = HASH_SEED * result + Objects.hashCode(toPattern);
    336        return result;
    337    }
    338
    339    /**
    340     * Get a custom format from a format description.
    341     *
    342     * @param desc String
    343     * @return Format
    344     */
    345    private Format getFormat(final String desc) {
    346        if (registry != null) {
    347            String name = desc;
    348            String args = null;
    349            final int i = desc.indexOf(START_FMT);
    350            if (i > 0) {
    351                name = desc.substring(0, i).trim();
    352                args = desc.substring(i + 1).trim();
    353            }
    354            final FormatFactory factory = registry.get(name);
    355            if (factory != null) {
    356                return factory.getFormat(name, args, getLocale());
    357            }
    358        }
    359        return null;
    360    }
    361
    362    /**
    363     * Read the argument index from the current format element.
    364     *
    365     * @param pattern pattern to parse
    366     * @param pos current parse position
    367     * @return argument index
    368     */
    369    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
    370        final int start = pos.getIndex();
    371        seekNonWs(pattern, pos);
    372        final StringBuilder result = new StringBuilder();
    373        boolean error = false;
    374        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
    375            char c = pattern.charAt(pos.getIndex());
    376            if (Character.isWhitespace(c)) {
    377                seekNonWs(pattern, pos);
    378                c = pattern.charAt(pos.getIndex());
    379                if (c != START_FMT && c != END_FE) {
    380                    error = true;
    381                    continue;
    382                }
    383            }
    384            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
    385                try {
    386                    return Integer.parseInt(result.toString());
    387                } catch (final NumberFormatException e) { // NOPMD
    388                    // we've already ensured only digits, so unless something
    389                    // outlandishly large was specified we should be okay.
    390                }
    391            }
    392            error = !Character.isDigit(c);
    393            result.append(c);
    394        }
    395        if (error) {
    396            throw new IllegalArgumentException(
    397                    "Invalid format argument index at position " + start + ": "
    398                            + pattern.substring(start, pos.getIndex()));
    399        }
    400        throw new IllegalArgumentException(
    401                "Unterminated format element at position " + start);
    402    }
    403
    404    /**
    405     * Parse the format component of a format element.
    406     *
    407     * @param pattern string to parse
    408     * @param pos current parse position
    409     * @return Format description String
    410     */
    411    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
    412        final int start = pos.getIndex();
    413        seekNonWs(pattern, pos);
    414        final int text = pos.getIndex();
    415        int depth = 1;
    416        while (pos.getIndex() < pattern.length()) {
    417            switch (pattern.charAt(pos.getIndex())) {
    418            case START_FE:
    419                depth++;
    420                next(pos);
    421                break;
    422            case END_FE:
    423                depth--;
    424                if (depth == 0) {
    425                    return pattern.substring(text, pos.getIndex());
    426                }
    427                next(pos);
    428                break;
    429            case QUOTE:
    430                getQuotedString(pattern, pos);
    431                break;
    432            default:
    433                next(pos);
    434                break;
    435            }
    436        }
    437        throw new IllegalArgumentException(
    438                "Unterminated format element at position " + start);
    439    }
    440
    441    /**
    442     * Insert formats back into the pattern for toPattern() support.
    443     *
    444     * @param pattern source
    445     * @param customPatterns The custom patterns to re-insert, if any
    446     * @return full pattern
    447     */
    448    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
    449        if (!containsElements(customPatterns)) {
    450            return pattern;
    451        }
    452        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
    453        final ParsePosition pos = new ParsePosition(0);
    454        int fe = -1;
    455        int depth = 0;
    456        while (pos.getIndex() < pattern.length()) {
    457            final char c = pattern.charAt(pos.getIndex());
    458            switch (c) {
    459            case QUOTE:
    460                appendQuotedString(pattern, pos, sb);
    461                break;
    462            case START_FE:
    463                depth++;
    464                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
    465                // do not look for custom patterns when they are embedded, e.g. in a choice
    466                if (depth == 1) {
    467                    fe++;
    468                    final String customPattern = customPatterns.get(fe);
    469                    if (customPattern != null) {
    470                        sb.append(START_FMT).append(customPattern);
    471                    }
    472                }
    473                break;
    474            case END_FE:
    475                depth--;
    476                //$FALL-THROUGH$
    477            default:
    478                sb.append(c);
    479                next(pos);
    480            }
    481        }
    482        return sb.toString();
    483    }
    484
    485    /**
    486     * Consume whitespace from the current parse position.
    487     *
    488     * @param pattern String to read
    489     * @param pos current position
    490     */
    491    private void seekNonWs(final String pattern, final ParsePosition pos) {
    492        int len = 0;
    493        final char[] buffer = pattern.toCharArray();
    494        do {
    495            len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
    496            pos.setIndex(pos.getIndex() + len);
    497        } while (len > 0 && pos.getIndex() < pattern.length());
    498    }
    499
    500    /**
    501     * Convenience method to advance parse position by 1.
    502     *
    503     * @param pos ParsePosition
    504     * @return <code>pos</code>
    505     */
    506    private ParsePosition next(final ParsePosition pos) {
    507        pos.setIndex(pos.getIndex() + 1);
    508        return pos;
    509    }
    510
    511    /**
    512     * Consume a quoted string, adding it to <code>appendTo</code> if
    513     * specified.
    514     *
    515     * @param pattern pattern to parse
    516     * @param pos current parse position
    517     * @param appendTo optional StringBuilder to append
    518     * @return <code>appendTo</code>
    519     */
    520    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
    521            final StringBuilder appendTo) {
    522        assert pattern.toCharArray()[pos.getIndex()] == QUOTE
    523                : "Quoted string must start with quote character";
    524
    525        // handle quote character at the beginning of the string
    526        if (appendTo != null) {
    527            appendTo.append(QUOTE);
    528        }
    529        next(pos);
    530
    531        final int start = pos.getIndex();
    532        final char[] c = pattern.toCharArray();
    533        final int lastHold = start;
    534        for (int i = pos.getIndex(); i < pattern.length(); i++) {
    535            switch (c[pos.getIndex()]) {
    536            case QUOTE:
    537                next(pos);
    538                return appendTo == null ? null : appendTo.append(c, lastHold,
    539                        pos.getIndex() - lastHold);
    540            default:
    541                next(pos);
    542            }
    543        }
    544        throw new IllegalArgumentException(
    545                "Unterminated quoted string at position " + start);
    546    }
    547
    548    /**
    549     * Consume quoted string only.
    550     *
    551     * @param pattern pattern to parse
    552     * @param pos current parse position
    553     */
    554    private void getQuotedString(final String pattern, final ParsePosition pos) {
    555        appendQuotedString(pattern, pos, null);
    556    }
    557
    558    /**
    559     * Learn whether the specified Collection contains non-null elements.
    560     * @param coll to check
    561     * @return <code>true</code> if some Object was found, <code>false</code> otherwise.
    562     */
    563    private boolean containsElements(final Collection<?> coll) {
    564        if (coll == null || coll.isEmpty()) {
    565            return false;
    566        }
    567        for (final Object name : coll) {
    568            if (name != null) {
    569                return true;
    570            }
    571        }
    572        return false;
    573    }
    574}