    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.lang3.StringUtils;
    031import org.apache.commons.text.matcher.StringMatcherFactory;
    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 {}<i>argument-number</i><b>(</b>{@code ,}<i>format-name</i><b>
    043 * (</b>{@code ,}<i>format-style</i><b>)?)?</b>{@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 1.0
    069 */
    070public class ExtendedMessageFormat extends MessageFormat {
    071
    072    /**
    073     * Serializable Object.
    074     */
    075    private static final long serialVersionUID = -2362048321261811743L;
    076
    077    /**
    078     * Our initial seed value for calculating hashes.
    079     */
    080    private static final int HASH_SEED = 31;
    081
    082    /**
    083     * The empty string.
    084     */
    085    private static final String DUMMY_PATTERN = StringUtils.EMPTY;
    086
    087    /**
    088     * A comma.
    089     */
    090    private static final char START_FMT = ',';
    091
    092    /**
    093     * A right side squiggly brace.
    094     */
    095    private static final char END_FE = '}';
    096
    097    /**
    098     * A left side squiggly brace.
    099     */
    100    private static final char START_FE = '{';
    101
    102    /**
    103     * A properly escaped character representing a single quote.
    104     */
    105    private static final char QUOTE = '\'';
    106
    107    /**
    108     * To pattern string.
    109     */
    110    private String toPattern;
    111
    112    /**
    113     * Our registry of FormatFactory's.
    114     */
    115    private final Map<String, ? extends FormatFactory> registry;
    116
    117    /**
    118     * Create a new ExtendedMessageFormat for the default locale.
    119     *
    120     * @param pattern  the pattern to use, not null
    121     * @throws IllegalArgumentException in case of a bad pattern.
    122     */
    123    public ExtendedMessageFormat(final String pattern) {
    124        this(pattern, Locale.getDefault(Category.FORMAT));
    125    }
    126
    127    /**
    128     * Create a new ExtendedMessageFormat.
    129     *
    130     * @param pattern  the pattern to use, not null
    131     * @param locale  the locale to use, not null
    132     * @throws IllegalArgumentException in case of a bad pattern.
    133     */
    134    public ExtendedMessageFormat(final String pattern, final Locale locale) {
    135        this(pattern, locale, null);
    136    }
    137
    138    /**
    139     * Create 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    /**
    151     * Create a new ExtendedMessageFormat.
    152     *
    153     * @param pattern  the pattern to use, not null
    154     * @param locale  the locale to use, not null
    155     * @param registry  the registry of format factories, may be null
    156     * @throws IllegalArgumentException in case of a bad pattern.
    157     */
    158    public ExtendedMessageFormat(final String pattern,
    159                                 final Locale locale,
    160                                 final Map<String, ? extends FormatFactory> registry) {
    161        super(DUMMY_PATTERN);
    162        setLocale(locale);
    163        this.registry = registry;
    164        applyPattern(pattern);
    165    }
    166
    167    /**
    168     * {@inheritDoc}
    169     */
    170    @Override
    171    public String toPattern() {
    172        return toPattern;
    173    }
    174
    175    /**
    176     * Apply the specified pattern.
    177     *
    178     * @param pattern String
    179     */
    180    @Override
    181    public final void applyPattern(final String pattern) {
    182        if (registry == null) {
    183            super.applyPattern(pattern);
    184            toPattern = super.toPattern();
    185            return;
    186        }
    187        final ArrayList<Format> foundFormats = new ArrayList<>();
    188        final ArrayList<String> foundDescriptions = new ArrayList<>();
    189        final StringBuilder stripCustom = new StringBuilder(pattern.length());
    190
    191        final ParsePosition pos = new ParsePosition(0);
    192        final char[] c = pattern.toCharArray();
    193        int fmtCount = 0;
    194        while (pos.getIndex() < pattern.length()) {
    195            switch (c[pos.getIndex()]) {
    196            case QUOTE:
    197                appendQuotedString(pattern, pos, stripCustom);
    198                break;
    199            case START_FE:
    200                fmtCount++;
    201                seekNonWs(pattern, pos);
    202                final int start = pos.getIndex();
    203                final int index = readArgumentIndex(pattern, next(pos));
    204                stripCustom.append(START_FE).append(index);
    205                seekNonWs(pattern, pos);
    206                Format format = null;
    207                String formatDescription = null;
    208                if (c[pos.getIndex()] == START_FMT) {
    209                    formatDescription = parseFormatDescription(pattern,
    210                            next(pos));
    211                    format = getFormat(formatDescription);
    212                    if (format == null) {
    213                        stripCustom.append(START_FMT).append(formatDescription);
    214                    }
    215                }
    216                foundFormats.add(format);
    217                foundDescriptions.add(format == null ? null : formatDescription);
    218                if (foundFormats.size() != fmtCount) {
    219                    throw new IllegalArgumentException("The validated expression is false");
    220                }
    221                if (foundDescriptions.size() != fmtCount) {
    222                    throw new IllegalArgumentException("The validated expression is false");
    223                }
    224                if (c[pos.getIndex()] != END_FE) {
    225                    throw new IllegalArgumentException(
    226                            "Unreadable format element at position " + start);
    227                }
    228                //$FALL-THROUGH$
    229            default:
    230                stripCustom.append(c[pos.getIndex()]);
    231                next(pos);
    232            }
    233        }
    234        super.applyPattern(stripCustom.toString());
    235        toPattern = insertFormats(super.toPattern(), foundDescriptions);
    236        if (containsElements(foundFormats)) {
    237            final Format[] origFormats = getFormats();
    238            // only loop over what we know we have, as MessageFormat on Java 1.3
    239            // seems to provide an extra format element:
    240            int i = 0;
    241            for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
    242                final Format f = it.next();
    243                if (f != null) {
    244                    origFormats[i] = f;
    245                }
    246            }
    247            super.setFormats(origFormats);
    248        }
    249    }
    250
    251    /**
    252     * Throws UnsupportedOperationException - see class Javadoc for details.
    253     *
    254     * @param formatElementIndex format element index
    255     * @param newFormat the new format
    256     * @throws UnsupportedOperationException always thrown since this isn't
    257     *                                       supported by ExtendMessageFormat
    258     */
    259    @Override
    260    public void setFormat(final int formatElementIndex, final Format newFormat) {
    261        throw new UnsupportedOperationException();
    262    }
    263
    264    /**
    265     * Throws UnsupportedOperationException - see class Javadoc for details.
    266     *
    267     * @param argumentIndex argument index
    268     * @param newFormat the new format
    269     * @throws UnsupportedOperationException always thrown since this isn't
    270     *                                       supported by ExtendMessageFormat
    271     */
    272    @Override
    273    public void setFormatByArgumentIndex(final int argumentIndex,
    274                                         final Format newFormat) {
    275        throw new UnsupportedOperationException();
    276    }
    277
    278    /**
    279     * Throws UnsupportedOperationException - see class Javadoc for details.
    280     *
    281     * @param newFormats new formats
    282     * @throws UnsupportedOperationException always thrown since this isn't
    283     *                                       supported by ExtendMessageFormat
    284     */
    285    @Override
    286    public void setFormats(final Format[] newFormats) {
    287        throw new UnsupportedOperationException();
    288    }
    289
    290    /**
    291     * Throws UnsupportedOperationException - see class Javadoc for details.
    292     *
    293     * @param newFormats new formats
    294     * @throws UnsupportedOperationException always thrown since this isn't
    295     *                                       supported by ExtendMessageFormat
    296     */
    297    @Override
    298    public void setFormatsByArgumentIndex(final Format[] newFormats) {
    299        throw new UnsupportedOperationException();
    300    }
    301
    302    /**
    303     * Check if this extended message format is equal to another object.
    304     *
    305     * @param obj the object to compare to
    306     * @return true if this object equals the other, otherwise false
    307     */
    308    @Override
    309    public boolean equals(final Object obj) {
    310        if (obj == this) {
    311            return true;
    312        }
    313        if (obj == null) {
    314            return false;
    315        }
    316        if (!Objects.equals(getClass(), obj.getClass())) {
    317          return false;
    318        }
    319        final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
    320        if (!Objects.equals(toPattern, rhs.toPattern)) {
    321            return false;
    322        }
    323        if (!super.equals(obj)) {
    324            return false;
    325        }
    326        return Objects.equals(registry, rhs.registry);
    327    }
    328
    329    /**
    330     * {@inheritDoc}
    331     */
    332    @Override
    333    public int hashCode() {
    334        int result = super.hashCode();
    335        result = HASH_SEED * result + Objects.hashCode(registry);
    336        result = HASH_SEED * result + Objects.hashCode(toPattern);
    337        return result;
    338    }
    339
    340    /**
    341     * Get a custom format from a format description.
    342     *
    343     * @param desc String
    344     * @return Format
    345     */
    346    private Format getFormat(final String desc) {
    347        if (registry != null) {
    348            String name = desc;
    349            String args = null;
    350            final int i = desc.indexOf(START_FMT);
    351            if (i > 0) {
    352                name = desc.substring(0, i).trim();
    353                args = desc.substring(i + 1).trim();
    354            }
    355            final FormatFactory factory = registry.get(name);
    356            if (factory != null) {
    357                return factory.getFormat(name, args, getLocale());
    358            }
    359        }
    360        return null;
    361    }
    362
    363    /**
    364     * Read the argument index from the current format element.
    365     *
    366     * @param pattern pattern to parse
    367     * @param pos current parse position
    368     * @return argument index
    369     */
    370    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
    371        final int start = pos.getIndex();
    372        seekNonWs(pattern, pos);
    373        final StringBuilder result = new StringBuilder();
    374        boolean error = false;
    375        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
    376            char c = pattern.charAt(pos.getIndex());
    377            if (Character.isWhitespace(c)) {
    378                seekNonWs(pattern, pos);
    379                c = pattern.charAt(pos.getIndex());
    380                if (c != START_FMT && c != END_FE) {
    381                    error = true;
    382                    continue;
    383                }
    384            }
    385            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
    386                try {
    387                    return Integer.parseInt(result.toString());
    388                } catch (final NumberFormatException e) { // NOPMD
    389                    // we've already ensured only digits, so unless something
    390                    // outlandishly large was specified we should be okay.
    391                }
    392            }
    393            error = !Character.isDigit(c);
    394            result.append(c);
    395        }
    396        if (error) {
    397            throw new IllegalArgumentException(
    398                    "Invalid format argument index at position " + start + ": "
    399                            + pattern.substring(start, pos.getIndex()));
    400        }
    401        throw new IllegalArgumentException(
    402                "Unterminated format element at position " + start);
    403    }
    404
    405    /**
    406     * Parse the format component of a format element.
    407     *
    408     * @param pattern string to parse
    409     * @param pos current parse position
    410     * @return Format description String
    411     */
    412    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
    413        final int start = pos.getIndex();
    414        seekNonWs(pattern, pos);
    415        final int text = pos.getIndex();
    416        int depth = 1;
    417        while (pos.getIndex() < pattern.length()) {
    418            switch (pattern.charAt(pos.getIndex())) {
    419            case START_FE:
    420                depth++;
    421                next(pos);
    422                break;
    423            case END_FE:
    424                depth--;
    425                if (depth == 0) {
    426                    return pattern.substring(text, pos.getIndex());
    427                }
    428                next(pos);
    429                break;
    430            case QUOTE:
    431                getQuotedString(pattern, pos);
    432                break;
    433            default:
    434                next(pos);
    435                break;
    436            }
    437        }
    438        throw new IllegalArgumentException(
    439                "Unterminated format element at position " + start);
    440    }
    441
    442    /**
    443     * Insert formats back into the pattern for toPattern() support.
    444     *
    445     * @param pattern source
    446     * @param customPatterns The custom patterns to re-insert, if any
    447     * @return full pattern
    448     */
    449    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
    450        if (!containsElements(customPatterns)) {
    451            return pattern;
    452        }
    453        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
    454        final ParsePosition pos = new ParsePosition(0);
    455        int fe = -1;
    456        int depth = 0;
    457        while (pos.getIndex() < pattern.length()) {
    458            final char c = pattern.charAt(pos.getIndex());
    459            switch (c) {
    460            case QUOTE:
    461                appendQuotedString(pattern, pos, sb);
    462                break;
    463            case START_FE:
    464                depth++;
    465                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
    466                // do not look for custom patterns when they are embedded, e.g. in a choice
    467                if (depth == 1) {
    468                    fe++;
    469                    final String customPattern = customPatterns.get(fe);
    470                    if (customPattern != null) {
    471                        sb.append(START_FMT).append(customPattern);
    472                    }
    473                }
    474                break;
    475            case END_FE:
    476                depth--;
    477                //$FALL-THROUGH$
    478            default:
    479                sb.append(c);
    480                next(pos);
    481            }
    482        }
    483        return sb.toString();
    484    }
    485
    486    /**
    487     * Consume whitespace from the current parse position.
    488     *
    489     * @param pattern String to read
    490     * @param pos current position
    491     */
    492    private void seekNonWs(final String pattern, final ParsePosition pos) {
    493        int len = 0;
    494        final char[] buffer = pattern.toCharArray();
    495        do {
    496            len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
    497            pos.setIndex(pos.getIndex() + len);
    498        } while (len > 0 && pos.getIndex() < pattern.length());
    499    }
    500
    501    /**
    502     * Convenience method to advance parse position by 1.
    503     *
    504     * @param pos ParsePosition
    505     * @return {@code pos}
    506     */
    507    private ParsePosition next(final ParsePosition pos) {
    508        pos.setIndex(pos.getIndex() + 1);
    509        return pos;
    510    }
    511
    512    /**
    513     * Consume a quoted string, adding it to {@code appendTo} if
    514     * specified.
    515     *
    516     * @param pattern pattern to parse
    517     * @param pos current parse position
    518     * @param appendTo optional StringBuilder to append
    519     * @return {@code appendTo}
    520     */
    521    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
    522            final StringBuilder appendTo) {
    523        assert pattern.toCharArray()[pos.getIndex()] == QUOTE
    524                : "Quoted string must start with quote character";
    525
    526        // handle quote character at the beginning of the string
    527        if (appendTo != null) {
    528            appendTo.append(QUOTE);
    529        }
    530        next(pos);
    531
    532        final int start = pos.getIndex();
    533        final char[] c = pattern.toCharArray();
    534        final int lastHold = start;
    535        for (int i = pos.getIndex(); i < pattern.length(); i++) {
    536            switch (c[pos.getIndex()]) {
    537            case QUOTE:
    538                next(pos);
    539                return appendTo == null ? null : appendTo.append(c, lastHold,
    540                        pos.getIndex() - lastHold);
    541            default:
    542                next(pos);
    543            }
    544        }
    545        throw new IllegalArgumentException(
    546                "Unterminated quoted string at position " + start);
    547    }
    548
    549    /**
    550     * Consume quoted string only.
    551     *
    552     * @param pattern pattern to parse
    553     * @param pos current parse position
    554     */
    555    private void getQuotedString(final String pattern, final ParsePosition pos) {
    556        appendQuotedString(pattern, pos, null);
    557    }
    558
    559    /**
    560     * Learn whether the specified Collection contains non-null elements.
    561     * @param coll to check
    562     * @return {@code true} if some Object was found, {@code false} otherwise.
    563     */
    564    private boolean containsElements(final Collection<?> coll) {
    565        if (coll == null || coll.isEmpty()) {
    566            return false;
    567        }
    568        for (final Object name : coll) {
    569            if (name != null) {
    570                return true;
    571            }
    572        }
    573        return false;
    574    }
    575}