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 */
068public class ExtendedMessageFormat extends MessageFormat {
069    private static final long serialVersionUID = -2362048321261811743L;
070    private static final int HASH_SEED = 31;
071
072    private static final String DUMMY_PATTERN = "";
073    private static final char START_FMT = ',';
074    private static final char END_FE = '}';
075    private static final char START_FE = '{';
076    private static final char QUOTE = '\'';
077
078    private String toPattern;
079    private final Map<String, ? extends FormatFactory> registry;
080
081    /**
082     * Create a new ExtendedMessageFormat for the default locale.
083     *
084     * @param pattern  the pattern to use, not null
085     * @throws IllegalArgumentException in case of a bad pattern.
086     */
087    public ExtendedMessageFormat(final String pattern) {
088        this(pattern, Locale.getDefault());
089    }
090
091    /**
092     * Create a new ExtendedMessageFormat.
093     *
094     * @param pattern  the pattern to use, not null
095     * @param locale  the locale to use, not null
096     * @throws IllegalArgumentException in case of a bad pattern.
097     */
098    public ExtendedMessageFormat(final String pattern, final Locale locale) {
099        this(pattern, locale, null);
100    }
101
102    /**
103     * Create a new ExtendedMessageFormat for the default locale.
104     *
105     * @param pattern  the pattern to use, not null
106     * @param registry  the registry of format factories, may be null
107     * @throws IllegalArgumentException in case of a bad pattern.
108     */
109    public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
110        this(pattern, Locale.getDefault(), registry);
111    }
112
113    /**
114     * Create a new ExtendedMessageFormat.
115     *
116     * @param pattern  the pattern to use, not null
117     * @param locale  the locale to use, not null
118     * @param registry  the registry of format factories, may be null
119     * @throws IllegalArgumentException in case of a bad pattern.
120     */
121    public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
122        super(DUMMY_PATTERN);
123        setLocale(locale);
124        this.registry = registry;
125        applyPattern(pattern);
126    }
127
128    /**
129     * {@inheritDoc}
130     */
131    @Override
132    public String toPattern() {
133        return toPattern;
134    }
135
136    /**
137     * Apply the specified pattern.
138     *
139     * @param pattern String
140     */
141    @Override
142    public final void applyPattern(final String pattern) {
143        if (registry == null) {
144            super.applyPattern(pattern);
145            toPattern = super.toPattern();
146            return;
147        }
148        final ArrayList<Format> foundFormats = new ArrayList<Format>();
149        final ArrayList<String> foundDescriptions = new ArrayList<String>();
150        final StringBuilder stripCustom = new StringBuilder(pattern.length());
151
152        final ParsePosition pos = new ParsePosition(0);
153        final char[] c = pattern.toCharArray();
154        int fmtCount = 0;
155        while (pos.getIndex() < pattern.length()) {
156            switch (c[pos.getIndex()]) {
157            case QUOTE:
158                appendQuotedString(pattern, pos, stripCustom);
159                break;
160            case START_FE:
161                fmtCount++;
162                seekNonWs(pattern, pos);
163                final int start = pos.getIndex();
164                final int index = readArgumentIndex(pattern, next(pos));
165                stripCustom.append(START_FE).append(index);
166                seekNonWs(pattern, pos);
167                Format format = null;
168                String formatDescription = null;
169                if (c[pos.getIndex()] == START_FMT) {
170                    formatDescription = parseFormatDescription(pattern,
171                            next(pos));
172                    format = getFormat(formatDescription);
173                    if (format == null) {
174                        stripCustom.append(START_FMT).append(formatDescription);
175                    }
176                }
177                foundFormats.add(format);
178                foundDescriptions.add(format == null ? null : formatDescription);
179                Validate.isTrue(foundFormats.size() == fmtCount);
180                Validate.isTrue(foundDescriptions.size() == fmtCount);
181                if (c[pos.getIndex()] != END_FE) {
182                    throw new IllegalArgumentException(
183                            "Unreadable format element at position " + start);
184                }
185                //$FALL-THROUGH$
186            default:
187                stripCustom.append(c[pos.getIndex()]);
188                next(pos);
189            }
190        }
191        super.applyPattern(stripCustom.toString());
192        toPattern = insertFormats(super.toPattern(), foundDescriptions);
193        if (containsElements(foundFormats)) {
194            final Format[] origFormats = getFormats();
195            // only loop over what we know we have, as MessageFormat on Java 1.3
196            // seems to provide an extra format element:
197            int i = 0;
198            for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
199                final Format f = it.next();
200                if (f != null) {
201                    origFormats[i] = f;
202                }
203            }
204            super.setFormats(origFormats);
205        }
206    }
207
208    /**
209     * Throws UnsupportedOperationException - see class Javadoc for details.
210     *
211     * @param formatElementIndex format element index
212     * @param newFormat the new format
213     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
214     */
215    @Override
216    public void setFormat(final int formatElementIndex, final Format newFormat) {
217        throw new UnsupportedOperationException();
218    }
219
220    /**
221     * Throws UnsupportedOperationException - see class Javadoc for details.
222     *
223     * @param argumentIndex argument index
224     * @param newFormat the new format
225     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
226     */
227    @Override
228    public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
229        throw new UnsupportedOperationException();
230    }
231
232    /**
233     * Throws UnsupportedOperationException - see class Javadoc for details.
234     *
235     * @param newFormats new formats
236     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
237     */
238    @Override
239    public void setFormats(final Format[] newFormats) {
240        throw new UnsupportedOperationException();
241    }
242
243    /**
244     * Throws UnsupportedOperationException - see class Javadoc for details.
245     *
246     * @param newFormats new formats
247     * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
248     */
249    @Override
250    public void setFormatsByArgumentIndex(final Format[] newFormats) {
251        throw new UnsupportedOperationException();
252    }
253
254    /**
255     * Check if this extended message format is equal to another object.
256     *
257     * @param obj the object to compare to
258     * @return true if this object equals the other, otherwise false
259     */
260    @Override
261    public boolean equals(final Object obj) {
262        if (obj == this) {
263            return true;
264        }
265        if (obj == null) {
266            return false;
267        }
268        if (!super.equals(obj)) {
269            return false;
270        }
271        if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
272          return false;
273        }
274        final ExtendedMessageFormat rhs = (ExtendedMessageFormat)obj;
275        if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
276            return false;
277        }
278        if (ObjectUtils.notEqual(registry, rhs.registry)) {
279            return false;
280        }
281        return true;
282    }
283
284    /**
285     * {@inheritDoc}
286     */
287    @SuppressWarnings( "deprecation" ) // ObjectUtils.hashCode(Object) has been deprecated in 3.2
288    @Override
289    public int hashCode() {
290        int result = super.hashCode();
291        result = HASH_SEED * result + ObjectUtils.hashCode(registry);
292        result = HASH_SEED * result + ObjectUtils.hashCode(toPattern);
293        return result;
294    }
295
296    /**
297     * Get a custom format from a format description.
298     *
299     * @param desc String
300     * @return Format
301     */
302    private Format getFormat(final String desc) {
303        if (registry != null) {
304            String name = desc;
305            String args = null;
306            final int i = desc.indexOf(START_FMT);
307            if (i > 0) {
308                name = desc.substring(0, i).trim();
309                args = desc.substring(i + 1).trim();
310            }
311            final FormatFactory factory = registry.get(name);
312            if (factory != null) {
313                return factory.getFormat(name, args, getLocale());
314            }
315        }
316        return null;
317    }
318
319    /**
320     * Read the argument index from the current format element
321     *
322     * @param pattern pattern to parse
323     * @param pos current parse position
324     * @return argument index
325     */
326    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
327        final int start = pos.getIndex();
328        seekNonWs(pattern, pos);
329        final StringBuilder result = new StringBuilder();
330        boolean error = false;
331        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
332            char c = pattern.charAt(pos.getIndex());
333            if (Character.isWhitespace(c)) {
334                seekNonWs(pattern, pos);
335                c = pattern.charAt(pos.getIndex());
336                if (c != START_FMT && c != END_FE) {
337                    error = true;
338                    continue;
339                }
340            }
341            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
342                try {
343                    return Integer.parseInt(result.toString());
344                } catch (final NumberFormatException e) { // NOPMD
345                    // we've already ensured only digits, so unless something
346                    // outlandishly large was specified we should be okay.
347                }
348            }
349            error = !Character.isDigit(c);
350            result.append(c);
351        }
352        if (error) {
353            throw new IllegalArgumentException(
354                    "Invalid format argument index at position " + start + ": "
355                            + pattern.substring(start, pos.getIndex()));
356        }
357        throw new IllegalArgumentException(
358                "Unterminated format element at position " + start);
359    }
360
361    /**
362     * Parse the format component of a format element.
363     *
364     * @param pattern string to parse
365     * @param pos current parse position
366     * @return Format description String
367     */
368    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
369        final int start = pos.getIndex();
370        seekNonWs(pattern, pos);
371        final int text = pos.getIndex();
372        int depth = 1;
373        for (; pos.getIndex() < pattern.length(); next(pos)) {
374            switch (pattern.charAt(pos.getIndex())) {
375            case START_FE:
376                depth++;
377                break;
378            case END_FE:
379                depth--;
380                if (depth == 0) {
381                    return pattern.substring(text, pos.getIndex());
382                }
383                break;
384            case QUOTE:
385                getQuotedString(pattern, pos);
386                break;
387            default:
388                break;
389            }
390        }
391        throw new IllegalArgumentException(
392                "Unterminated format element at position " + start);
393    }
394
395    /**
396     * Insert formats back into the pattern for toPattern() support.
397     *
398     * @param pattern source
399     * @param customPatterns The custom patterns to re-insert, if any
400     * @return full pattern
401     */
402    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
403        if (!containsElements(customPatterns)) {
404            return pattern;
405        }
406        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
407        final ParsePosition pos = new ParsePosition(0);
408        int fe = -1;
409        int depth = 0;
410        while (pos.getIndex() < pattern.length()) {
411            final char c = pattern.charAt(pos.getIndex());
412            switch (c) {
413            case QUOTE:
414                appendQuotedString(pattern, pos, sb);
415                break;
416            case START_FE:
417                depth++;
418                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
419                // do not look for custom patterns when they are embedded, e.g. in a choice
420                if (depth == 1) {
421                    fe++;
422                    final String customPattern = customPatterns.get(fe);
423                    if (customPattern != null) {
424                        sb.append(START_FMT).append(customPattern);
425                    }
426                }
427                break;
428            case END_FE:
429                depth--;
430                //$FALL-THROUGH$
431            default:
432                sb.append(c);
433                next(pos);
434            }
435        }
436        return sb.toString();
437    }
438
439    /**
440     * Consume whitespace from the current parse position.
441     *
442     * @param pattern String to read
443     * @param pos current position
444     */
445    private void seekNonWs(final String pattern, final ParsePosition pos) {
446        int len = 0;
447        final char[] buffer = pattern.toCharArray();
448        do {
449            len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
450            pos.setIndex(pos.getIndex() + len);
451        } while (len > 0 && pos.getIndex() < pattern.length());
452    }
453
454    /**
455     * Convenience method to advance parse position by 1
456     *
457     * @param pos ParsePosition
458     * @return <code>pos</code>
459     */
460    private ParsePosition next(final ParsePosition pos) {
461        pos.setIndex(pos.getIndex() + 1);
462        return pos;
463    }
464
465    /**
466     * Consume a quoted string, adding it to <code>appendTo</code> if
467     * specified.
468     *
469     * @param pattern pattern to parse
470     * @param pos current parse position
471     * @param appendTo optional StringBuilder to append
472     * @return <code>appendTo</code>
473     */
474    private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
475            final StringBuilder appendTo) {
476        assert pattern.toCharArray()[pos.getIndex()] == QUOTE : 
477            "Quoted string must start with quote character";
478
479        // handle quote character at the beginning of the string
480        if(appendTo != null) {
481            appendTo.append(QUOTE);
482        }
483        next(pos);
484
485        final int start = pos.getIndex();
486        final char[] c = pattern.toCharArray();
487        int lastHold = start;
488        for (int i = pos.getIndex(); i < pattern.length(); i++) {
489            switch (c[pos.getIndex()]) {
490            case QUOTE:
491                next(pos);
492                return appendTo == null ? null : appendTo.append(c, lastHold,
493                        pos.getIndex() - lastHold);
494            default:
495                next(pos);
496            }
497        }
498        throw new IllegalArgumentException(
499                "Unterminated quoted string at position " + start);
500    }
501
502    /**
503     * Consume quoted string only
504     *
505     * @param pattern pattern to parse
506     * @param pos current parse position
507     */
508    private void getQuotedString(final String pattern, final ParsePosition pos) {
509        appendQuotedString(pattern, pos, null);
510    }
511
512    /**
513     * Learn whether the specified Collection contains non-null elements.
514     * @param coll to check
515     * @return <code>true</code> if some Object was found, <code>false</code> otherwise.
516     */
517    private boolean containsElements(final Collection<?> coll) {
518        if (coll == null || coll.isEmpty()) {
519            return false;
520        }
521        for (final Object name : coll) {
522            if (name != null) {
523                return true;
524            }
525        }
526        return false;
527    }
528}