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