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