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.lang3.text;
018
019import java.text.Format;
020import java.text.MessageFormat;
021import java.text.ParsePosition;
022import java.util.ArrayList;
023import java.util.Collection;
024import java.util.Iterator;
025import java.util.Locale;
026import java.util.Map;
027
028import org.apache.commons.lang3.ObjectUtils;
029import 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 1565257 2014-02-06 13:51:45Z sebb $
069 */
070public 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(final 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(final String pattern, final 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(final String pattern, final 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(final String pattern, final Locale locale, final 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(final String pattern) {
146        if (registry == null) {
147            super.applyPattern(pattern);
148            toPattern = super.toPattern();
149            return;
150        }
151        final ArrayList<Format> foundFormats = new ArrayList<Format>();
152        final ArrayList<String> foundDescriptions = new ArrayList<String>();
153        final StringBuilder stripCustom = new StringBuilder(pattern.length());
154
155        final ParsePosition pos = new ParsePosition(0);
156        final 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                final int start = pos.getIndex();
167                final 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            final 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 (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
202                final 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(final int formatElementIndex, final 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(final int argumentIndex, final 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(final 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(final 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(final 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        final 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    @SuppressWarnings( "deprecation" ) // ObjectUtils.hashCode(Object) has been deprecated in 3.2
293    @Override
294    public int hashCode() {
295        int result = super.hashCode();
296        result = HASH_SEED * result + ObjectUtils.hashCode(registry);
297        result = HASH_SEED * result + ObjectUtils.hashCode(toPattern);
298        return result;
299    }
300
301    /**
302     * Get a custom format from a format description.
303     *
304     * @param desc String
305     * @return Format
306     */
307    private Format getFormat(final String desc) {
308        if (registry != null) {
309            String name = desc;
310            String args = null;
311            final int i = desc.indexOf(START_FMT);
312            if (i > 0) {
313                name = desc.substring(0, i).trim();
314                args = desc.substring(i + 1).trim();
315            }
316            final FormatFactory factory = registry.get(name);
317            if (factory != null) {
318                return factory.getFormat(name, args, getLocale());
319            }
320        }
321        return null;
322    }
323
324    /**
325     * Read the argument index from the current format element
326     *
327     * @param pattern pattern to parse
328     * @param pos current parse position
329     * @return argument index
330     */
331    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
332        final int start = pos.getIndex();
333        seekNonWs(pattern, pos);
334        final StringBuilder result = new StringBuilder();
335        boolean error = false;
336        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
337            char c = pattern.charAt(pos.getIndex());
338            if (Character.isWhitespace(c)) {
339                seekNonWs(pattern, pos);
340                c = pattern.charAt(pos.getIndex());
341                if (c != START_FMT && c != END_FE) {
342                    error = true;
343                    continue;
344                }
345            }
346            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
347                try {
348                    return Integer.parseInt(result.toString());
349                } catch (final NumberFormatException e) { // NOPMD
350                    // we've already ensured only digits, so unless something
351                    // outlandishly large was specified we should be okay.
352                }
353            }
354            error = !Character.isDigit(c);
355            result.append(c);
356        }
357        if (error) {
358            throw new IllegalArgumentException(
359                    "Invalid format argument index at position " + start + ": "
360                            + pattern.substring(start, pos.getIndex()));
361        }
362        throw new IllegalArgumentException(
363                "Unterminated format element at position " + start);
364    }
365
366    /**
367     * Parse the format component of a format element.
368     *
369     * @param pattern string to parse
370     * @param pos current parse position
371     * @return Format description String
372     */
373    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
374        final int start = pos.getIndex();
375        seekNonWs(pattern, pos);
376        final int text = pos.getIndex();
377        int depth = 1;
378        for (; pos.getIndex() < pattern.length(); next(pos)) {
379            switch (pattern.charAt(pos.getIndex())) {
380            case START_FE:
381                depth++;
382                break;
383            case END_FE:
384                depth--;
385                if (depth == 0) {
386                    return pattern.substring(text, pos.getIndex());
387                }
388                break;
389            case QUOTE:
390                getQuotedString(pattern, pos, false);
391                break;
392            default:
393                break;
394            }
395        }
396        throw new IllegalArgumentException(
397                "Unterminated format element at position " + start);
398    }
399
400    /**
401     * Insert formats back into the pattern for toPattern() support.
402     *
403     * @param pattern source
404     * @param customPatterns The custom patterns to re-insert, if any
405     * @return full pattern
406     */
407    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
408        if (!containsElements(customPatterns)) {
409            return pattern;
410        }
411        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
412        final ParsePosition pos = new ParsePosition(0);
413        int fe = -1;
414        int depth = 0;
415        while (pos.getIndex() < pattern.length()) {
416            final char c = pattern.charAt(pos.getIndex());
417            switch (c) {
418            case QUOTE:
419                appendQuotedString(pattern, pos, sb, false);
420                break;
421            case START_FE:
422                depth++;
423                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
424                // do not look for custom patterns when they are embedded, e.g. in a choice
425                if (depth == 1) {
426                    fe++;
427                    final String customPattern = customPatterns.get(fe);
428                    if (customPattern != null) {
429                        sb.append(START_FMT).append(customPattern);
430                    }
431                }
432                break;
433            case END_FE:
434                depth--;
435                //$FALL-THROUGH$
436            default:
437                sb.append(c);
438                next(pos);
439            }
440        }
441        return sb.toString();
442    }
443
444    /**
445     * Consume whitespace from the current parse position.
446     *
447     * @param pattern String to read
448     * @param pos current position
449     */
450    private void seekNonWs(final String pattern, final ParsePosition pos) {
451        int len = 0;
452        final char[] buffer = pattern.toCharArray();
453        do {
454            len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
455            pos.setIndex(pos.getIndex() + len);
456        } while (len > 0 && pos.getIndex() < pattern.length());
457    }
458
459    /**
460     * Convenience method to advance parse position by 1
461     *
462     * @param pos ParsePosition
463     * @return <code>pos</code>
464     */
465    private ParsePosition next(final ParsePosition pos) {
466        pos.setIndex(pos.getIndex() + 1);
467        return pos;
468    }
469
470    /**
471     * Consume a quoted string, adding it to <code>appendTo</code> if
472     * specified.
473     *
474     * @param pattern pattern to parse
475     * @param pos current parse position
476     * @param appendTo optional StringBuilder to append
477     * @param escapingOn whether to process escaped quotes
478     * @return <code>appendTo</code>
479     */
480    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
481            final StringBuilder appendTo, final boolean escapingOn) {
482        final int start = pos.getIndex();
483        final char[] c = pattern.toCharArray();
484        if (escapingOn && c[start] == QUOTE) {
485            next(pos);
486            return appendTo == null ? null : appendTo.append(QUOTE);
487        }
488        int lastHold = start;
489        for (int i = pos.getIndex(); i < pattern.length(); i++) {
490            if (escapingOn && pattern.substring(i).startsWith(ESCAPED_QUOTE)) {
491                appendTo.append(c, lastHold, pos.getIndex() - lastHold).append(
492                        QUOTE);
493                pos.setIndex(i + ESCAPED_QUOTE.length());
494                lastHold = pos.getIndex();
495                continue;
496            }
497            switch (c[pos.getIndex()]) {
498            case QUOTE:
499                next(pos);
500                return appendTo == null ? null : appendTo.append(c, lastHold,
501                        pos.getIndex() - lastHold);
502            default:
503                next(pos);
504            }
505        }
506        throw new IllegalArgumentException(
507                "Unterminated quoted string at position " + start);
508    }
509
510    /**
511     * Consume quoted string only
512     *
513     * @param pattern pattern to parse
514     * @param pos current parse position
515     * @param escapingOn whether to process escaped quotes
516     */
517    private void getQuotedString(final String pattern, final ParsePosition pos,
518            final boolean escapingOn) {
519        appendQuotedString(pattern, pos, null, escapingOn);
520    }
521
522    /**
523     * Learn whether the specified Collection contains non-null elements.
524     * @param coll to check
525     * @return <code>true</code> if some Object was found, <code>false</code> otherwise.
526     */
527    private boolean containsElements(final Collection<?> coll) {
528        if (coll == null || coll.isEmpty()) {
529            return false;
530        }
531        for (final Object name : coll) {
532            if (name != null) {
533                return true;
534            }
535        }
536        return false;
537    }
538}