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