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