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>:</p>
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 *
066 * @since 2.4
067 * @version $Id: ExtendedMessageFormat.java 1585282 2014-04-06 10:43:47Z britter $
068 */
069public class ExtendedMessageFormat extends MessageFormat {
070    private static final long serialVersionUID = -2362048321261811743L;
071    private static final int HASH_SEED = 31;
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(final 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(final String pattern, final 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(final String pattern, final 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(final String pattern, final Locale locale, final 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(final String pattern) {
145        if (registry == null) {
146            super.applyPattern(pattern);
147            toPattern = super.toPattern();
148            return;
149        }
150        final ArrayList<Format> foundFormats = new ArrayList<Format>();
151        final ArrayList<String> foundDescriptions = new ArrayList<String>();
152        final StringBuilder stripCustom = new StringBuilder(pattern.length());
153
154        final ParsePosition pos = new ParsePosition(0);
155        final 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                final int start = pos.getIndex();
166                final 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            final 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 (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
201                final Format f = it.next();
202                if (f != null) {
203                    origFormats[i] = f;
204                }
205            }
206            super.setFormats(origFormats);
207        }
208    }
209
210    /**
211     * Throws UnsupportedOperationException - see class Javadoc for details.
212     *
213     * @param formatElementIndex format element index
214     * @param newFormat the new format
215     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
216     */
217    @Override
218    public void setFormat(final int formatElementIndex, final Format newFormat) {
219        throw new UnsupportedOperationException();
220    }
221
222    /**
223     * Throws UnsupportedOperationException - see class Javadoc for details.
224     *
225     * @param argumentIndex argument index
226     * @param newFormat the new format
227     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
228     */
229    @Override
230    public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
231        throw new UnsupportedOperationException();
232    }
233
234    /**
235     * Throws UnsupportedOperationException - see class Javadoc for details.
236     *
237     * @param newFormats new formats
238     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
239     */
240    @Override
241    public void setFormats(final Format[] newFormats) {
242        throw new UnsupportedOperationException();
243    }
244
245    /**
246     * Throws UnsupportedOperationException - see class Javadoc for details.
247     *
248     * @param newFormats new formats
249     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
250     */
251    @Override
252    public void setFormatsByArgumentIndex(final Format[] newFormats) {
253        throw new UnsupportedOperationException();
254    }
255
256    /**
257     * Check if this extended message format is equal to another object.
258     *
259     * @param obj the object to compare to
260     * @return true if this object equals the other, otherwise false
261     */
262    @Override
263    public boolean equals(final Object obj) {
264        if (obj == this) {
265            return true;
266        }
267        if (obj == null) {
268            return false;
269        }
270        if (!super.equals(obj)) {
271            return false;
272        }
273        if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
274          return false;
275        }
276        final ExtendedMessageFormat rhs = (ExtendedMessageFormat)obj;
277        if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
278            return false;
279        }
280        if (ObjectUtils.notEqual(registry, rhs.registry)) {
281            return false;
282        }
283        return true;
284    }
285
286    /**
287     * {@inheritDoc}
288     */
289    @SuppressWarnings( "deprecation" ) // ObjectUtils.hashCode(Object) has been deprecated in 3.2
290    @Override
291    public int hashCode() {
292        int result = super.hashCode();
293        result = HASH_SEED * result + ObjectUtils.hashCode(registry);
294        result = HASH_SEED * result + ObjectUtils.hashCode(toPattern);
295        return result;
296    }
297
298    /**
299     * Get a custom format from a format description.
300     *
301     * @param desc String
302     * @return Format
303     */
304    private Format getFormat(final String desc) {
305        if (registry != null) {
306            String name = desc;
307            String args = null;
308            final int i = desc.indexOf(START_FMT);
309            if (i > 0) {
310                name = desc.substring(0, i).trim();
311                args = desc.substring(i + 1).trim();
312            }
313            final FormatFactory factory = registry.get(name);
314            if (factory != null) {
315                return factory.getFormat(name, args, getLocale());
316            }
317        }
318        return null;
319    }
320
321    /**
322     * Read the argument index from the current format element
323     *
324     * @param pattern pattern to parse
325     * @param pos current parse position
326     * @return argument index
327     */
328    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
329        final int start = pos.getIndex();
330        seekNonWs(pattern, pos);
331        final StringBuilder result = new StringBuilder();
332        boolean error = false;
333        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
334            char c = pattern.charAt(pos.getIndex());
335            if (Character.isWhitespace(c)) {
336                seekNonWs(pattern, pos);
337                c = pattern.charAt(pos.getIndex());
338                if (c != START_FMT && c != END_FE) {
339                    error = true;
340                    continue;
341                }
342            }
343            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
344                try {
345                    return Integer.parseInt(result.toString());
346                } catch (final NumberFormatException e) { // NOPMD
347                    // we've already ensured only digits, so unless something
348                    // outlandishly large was specified we should be okay.
349                }
350            }
351            error = !Character.isDigit(c);
352            result.append(c);
353        }
354        if (error) {
355            throw new IllegalArgumentException(
356                    "Invalid format argument index at position " + start + ": "
357                            + pattern.substring(start, pos.getIndex()));
358        }
359        throw new IllegalArgumentException(
360                "Unterminated format element at position " + start);
361    }
362
363    /**
364     * Parse the format component of a format element.
365     *
366     * @param pattern string to parse
367     * @param pos current parse position
368     * @return Format description String
369     */
370    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
371        final int start = pos.getIndex();
372        seekNonWs(pattern, pos);
373        final int text = pos.getIndex();
374        int depth = 1;
375        for (; pos.getIndex() < pattern.length(); next(pos)) {
376            switch (pattern.charAt(pos.getIndex())) {
377            case START_FE:
378                depth++;
379                break;
380            case END_FE:
381                depth--;
382                if (depth == 0) {
383                    return pattern.substring(text, pos.getIndex());
384                }
385                break;
386            case QUOTE:
387                getQuotedString(pattern, pos, false);
388                break;
389            default:
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(final String pattern, final ArrayList<String> customPatterns) {
405        if (!containsElements(customPatterns)) {
406            return pattern;
407        }
408        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
409        final ParsePosition pos = new ParsePosition(0);
410        int fe = -1;
411        int depth = 0;
412        while (pos.getIndex() < pattern.length()) {
413            final 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                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
421                // do not look for custom patterns when they are embedded, e.g. in a choice
422                if (depth == 1) {
423                    fe++;
424                    final 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(final String pattern, final ParsePosition pos) {
448        int len = 0;
449        final 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(final 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 StringBuilder to append
474     * @param escapingOn whether to process escaped quotes
475     * @return <code>appendTo</code>
476     */
477    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
478            final StringBuilder appendTo, final boolean escapingOn) {
479        final int start = pos.getIndex();
480        final 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(final String pattern, final ParsePosition pos,
515            final 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(final Collection<?> coll) {
525        if (coll == null || coll.isEmpty()) {
526            return false;
527        }
528        for (final Object name : coll) {
529            if (name != null) {
530                return true;
531            }
532        }
533        return false;
534    }
535}