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