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