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